diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..cfa8b762e49 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,135 @@ +version: 2 + +jobs: + test-frontend: + docker: + - image: circleci/node:6.11.4 + steps: + - checkout + - run: + name: install yarn + command: 'sudo npm install -g yarn --quiet' + - restore_cache: + key: dependency-cache-{{ checksum "yarn.lock" }} + # Could we skip this step if the cache has been restored? `[ -d node_modules ] || yarn install ...` should be able to apply to build step as well + - run: + name: yarn install + command: 'yarn install --pure-lockfile --no-progress' + - save_cache: + key: dependency-cache-{{ checksum "yarn.lock" }} + paths: + - node_modules + - run: + name: frontend tests + command: './scripts/circle-test-frontend.sh' + + test-backend: + docker: + - image: circleci/golang:1.10 + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - run: + name: build backend and run go tests + command: './scripts/circle-test-backend.sh' + + build: + docker: + - image: grafana/build-container:v0.1 + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - 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' + - run: + name: Build Grafana.com publisher + command: 'go build -o scripts/publish scripts/build/publish.go' + - persist_to_workspace: + root: . + paths: + - dist/grafana* + - scripts/*.sh + - scripts/publish + + deploy-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 ./dist s3://$BUCKET_NAME/master' + - 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}' + - run: + name: Publish to Grafana.com + command: './scripts/publish -apiKey ${GRAFANA_COM_API_KEY}' + + deploy-release: + docker: + - image: circleci/python:2.7-stretch + steps: + - attach_workspace: + at: dist + - run: + name: install awscli + command: 'sudo pip install awscli' + - run: + name: deploy to s3 + command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release' + - 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: + jobs: + - build: + filters: + tags: + only: /.*/ + - test-frontend: + filters: + tags: + only: /.*/ + - test-backend: + filters: + tags: + only: /.*/ + - deploy-master: + requires: + - test-backend + - test-frontend + - build + filters: + branches: + only: master + - deploy-release: + requires: + - test-backend + - test-frontend + - build + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 082482fcb74..8086a6b86e5 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -5,12 +5,12 @@ Read before posting: - Checkout How to troubleshoot metric query issues: https://community.grafana.com/t/how-to-troubleshoot-metric-query-issues/50 Please include this information: -- What Grafana version are you using? -- What datasource are you using? -- What OS are you running grafana on? -- What did you do? -- What was the expected result? -- What happened instead? -- If related to metric query / data viz: - - Include raw network request & response: get by opening Chrome Dev Tools (F12, Ctrl+Shift+I on windows, Cmd+Opt+I on Mac), go the network tab. +### What Grafana version are you using? +### What datasource are you using? +### What OS are you running grafana on? +### What did you do? +### What was the expected result? +### What happened instead? +### If related to metric query / data viz: +### Include raw network request & response: get by opening Chrome Dev Tools (F12, Ctrl+Shift+I on windows, Cmd+Opt+I on Mac), go the network tab. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6508c4ff76a..03ad228f3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,113 @@ -# 5.0.0-beta2 (unrelased) +# 5.1.0 (unreleased) + +* **MSSQL**: New Microsoft SQL Server data source [#10093](https://github.com/grafana/grafana/pull/10093), [#11298](https://github.com/grafana/grafana/pull/11298), thx [@linuxchips](https://github.com/linuxchips) +* **Prometheus**: The heatmap panel now support Prometheus histograms [#10009](https://github.com/grafana/grafana/issues/10009) +* **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: Fix precision for the time column in table mode [#11306](https://github.com/grafana/grafana/issues/11306) +* **Graph**: Align left and right Y-axes to one level [#1271](https://github.com/grafana/grafana/issues/1271) & [#2740](https://github.com/grafana/grafana/issues/2740) thx [@ilgizar](https://github.com/ilgizar) +* **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar) +* **Graph**: Support multiple series stacking in histogram mode [#8151](https://github.com/grafana/grafana/issues/8151), thx [@mtanda](https://github.com/mtanda) +* **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942) +* **Alerting**: Support Pagerduty notification channel using Pagerduty V2 API [#10531](https://github.com/grafana/grafana/issues/10531), thx [@jbaublitz](https://github.com/jbaublitz) +* **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda) +* **Prometheus**: Show template variable candidate in query editor [#9210](https://github.com/grafana/grafana/issues/9210), thx [@mtanda](https://github.com/mtanda) +* **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda) +* **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17) + +### Minor +* **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes) +* **Cloudwatch**: Support high resolution metrics [#10925](https://github.com/grafana/grafana/pull/10925), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Add dimension filtering to CloudWatch `dimension_values()` [#10029](https://github.com/grafana/grafana/issues/10029), thx [@willyhutw](https://github.com/willyhutw) +* **Units**: Second to HH:mm:ss formatter [#11107](https://github.com/grafana/grafana/issues/11107), thx [@gladdiologist](https://github.com/gladdiologist) +* **Singlestat**: Add color to prefix and postfix in singlestat panel [#11143](https://github.com/grafana/grafana/pull/11143), thx [@ApsOps](https://github.com/ApsOps) +* **Dashboards**: Version cleanup fails on old databases with many entries [#11278](https://github.com/grafana/grafana/issues/11278) +* **Server**: Adjust permissions of unix socket [#11343](https://github.com/grafana/grafana/pull/11343), thx [@corny](https://github.com/corny) +* **Shortcuts**: Add shortcut for duplicate panel [#11102](https://github.com/grafana/grafana/issues/11102) +* **AuthProxy**: Support IPv6 in Auth proxy white list [#11330](https://github.com/grafana/grafana/pull/11330), thx [@corny](https://github.com/corny) +* **SMTP**: Don't connect to STMP server using TLS unless configured. [#7189](https://github.com/grafana/grafana/issues/7189) + +# 5.0.4 (2018-03-28) + +* **Docker** Can't start Grafana on Kubernetes 1.7.14, 1.8.9, or 1.9.4 [#140 in grafana-docker repo](https://github.com/grafana/grafana-docker/issues/140) thx [@suquant](https://github.com/suquant) +* **Dashboard** Fixed bug where collapsed panels could not be directly linked to/renderer [#11114](https://github.com/grafana/grafana/issues/11114) & [#11086](https://github.com/grafana/grafana/issues/11086) & [#11296](https://github.com/grafana/grafana/issues/11296) +* **Dashboard** Provisioning dashboard with alert rules should create alerts [#11247](https://github.com/grafana/grafana/issues/11247) +* **Snapshots** For snapshots, the Graph panel renders the legend incorrectly on right hand side [#11318](https://github.com/grafana/grafana/issues/11318) +* **Alerting** Link back to Grafana returns wrong URL if root_path contains sub-path components [#11403](https://github.com/grafana/grafana/issues/11403) +* **Alerting** Incorrect default value for upload images setting for alert notifiers [#11413](https://github.com/grafana/grafana/pull/11413) + +# 5.0.3 (2018-03-16) +* **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access (a bigger patch than the one in 5.0.2) [#11155](https://github.com/grafana/grafana/issues/11155) + +# 5.0.2 (2018-03-14) +* **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access [#11155](https://github.com/grafana/grafana/issues/11155) +* **Dashboards**: Should be possible to browse dashboard using only uid [#11231](https://github.com/grafana/grafana/issues/11231) +* **Alerting**: Fixes bug where alerts from hidden panels where deleted [#11222](https://github.com/grafana/grafana/issues/11222) +* **Import**: Fixes bug where dashboards with alerts couldn't be imported [#11227](https://github.com/grafana/grafana/issues/11227) +* **Teams**: Remove quota restrictions from teams [#11220](https://github.com/grafana/grafana/issues/11220) +* **Render**: Fixes bug with legacy url redirection for panel rendering [#11180](https://github.com/grafana/grafana/issues/11180) + +# 5.0.1 (2018-03-08) + +* **Postgres**: PostgreSQL error when using ipv6 address as hostname in connection string [#11055](https://github.com/grafana/grafana/issues/11055), thanks [@svenklemm](https://github.com/svenklemm) +* **Dashboards**: Changing templated value from dropdown is causing unsaved changes [#11063](https://github.com/grafana/grafana/issues/11063) +* **Prometheus**: Fixes bundled Prometheus 2.0 dashboard [#11016](https://github.com/grafana/grafana/issues/11016), thx [@roidelapluie](https://github.com/roidelapluie) +* **Sidemenu**: Profile menu "invisible" when gravatar is disabled [#11097](https://github.com/grafana/grafana/issues/11097) +* **Dashboard**: Fixes a bug with resizeable handles for panels [#11103](https://github.com/grafana/grafana/issues/11103) +* **Alerting**: Telegram inline image mode fails when caption too long [#10975](https://github.com/grafana/grafana/issues/10975) +* **Alerting**: Fixes silent failing validation [#11145](https://github.com/grafana/grafana/pull/11145) +* **OAuth**: Only use jwt token if it contains an email address [#11127](https://github.com/grafana/grafana/pull/11127) + +# 5.0.0-stable (2018-03-01) + +### Fixes + +- **oauth** Fix Github OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick) +- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010) +- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker) + +# 5.0.0-beta5 (2018-02-26) + +### Fixes + +- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774) +- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630) +- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982) +- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124) +- **oauth** Github OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964) +- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793) +- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996) + +# 5.0.0-beta4 (2018-02-19) + +### Fixes + +- **Dashboard** Fixed dashboard overwrite permission issue [#10814](https://github.com/grafana/grafana/issues/10814) +- **Keyboard shortcuts** Fixed Esc key when in panel edit/view mode [#10945](https://github.com/grafana/grafana/issues/10945) +- **Save dashboard** Fixed issue with time range & variable reset after saving [#10946](https://github.com/grafana/grafana/issues/10946) + +# 5.0.0-beta3 (2018-02-16) + +### Fixes + +- **MySQL** Fixed new migration issue with index length [#10931](https://github.com/grafana/grafana/issues/10931) +- **Modal** Escape key no closes modals everywhere, fixes [#10887](https://github.com/grafana/grafana/issues/10887) +- **Row repeats** Fix for repeating rows issue, fixes [#10932](https://github.com/grafana/grafana/issues/10932) +- **Docs** Team api documented, fixes [#10832](https://github.com/grafana/grafana/issues/10832) +- **Plugins** Plugin info page broken, fixes [#10943](https://github.com/grafana/grafana/issues/10943) + +# 5.0.0-beta2 (2018-02-15) + +### Fixes + +- **Permissions** Fixed search permissions issues [#10822](https://github.com/grafana/grafana/issues/10822) +- **Permissions** Fixed problem issues displaying permissions lists [#10864](https://github.com/grafana/grafana/issues/10864) +- **PNG-Rendering** Fixed problem rendering legend to the right [#10526](https://github.com/grafana/grafana/issues/10526) +- **Reset password** Fixed problem with reset password form [#10870](https://github.com/grafana/grafana/issues/10870) +- **Light theme** Fixed problem with light theme in safari, [#10869](https://github.com/grafana/grafana/issues/10869) +- **Provisioning** Now handles deletes when dashboard json files removed from disk [#10865](https://github.com/grafana/grafana/issues/10865) +- **MySQL** Fixed issue with schema migration on old mysql (index too long) [#10779](https://github.com/grafana/grafana/issues/10779) +- **Github OAuth** Fixed fetching github orgs from private github org [#10823](https://github.com/grafana/grafana/issues/10823) +- **Embedding** Fixed issues embedding panel [#10787](https://github.com/grafana/grafana/issues/10787) # 5.0.0-beta1 (2018-02-05) diff --git a/Gopkg.lock b/Gopkg.lock index 8d82b29d622..a35f5b23cda 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -27,37 +27,7 @@ [[projects]] name = "github.com/aws/aws-sdk-go" - packages = [ - "aws", - "aws/awserr", - "aws/awsutil", - "aws/client", - "aws/client/metadata", - "aws/corehandlers", - "aws/credentials", - "aws/credentials/ec2rolecreds", - "aws/credentials/endpointcreds", - "aws/credentials/stscreds", - "aws/defaults", - "aws/ec2metadata", - "aws/endpoints", - "aws/request", - "aws/session", - "aws/signer/v4", - "internal/shareddefaults", - "private/protocol", - "private/protocol/ec2query", - "private/protocol/query", - "private/protocol/query/queryutil", - "private/protocol/rest", - "private/protocol/restxml", - "private/protocol/xml/xmlutil", - "service/cloudwatch", - "service/ec2", - "service/ec2/ec2iface", - "service/s3", - "service/sts" - ] + packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/ec2query","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/restxml","private/protocol/xml/xmlutil","service/cloudwatch","service/ec2","service/ec2/ec2iface","service/s3","service/sts"] revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28" version = "v1.12.67" @@ -103,6 +73,11 @@ revision = "346938d642f2ec3594ed81d874461961cd0faa76" version = "v1.1.0" +[[projects]] + name = "github.com/denisenkom/go-mssqldb" + packages = [".","internal/cp"] + revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" + [[projects]] name = "github.com/fatih/color" packages = ["."] @@ -142,13 +117,7 @@ [[projects]] branch = "master" name = "github.com/go-macaron/session" - packages = [ - ".", - "memcache", - "mysql", - "postgres", - "redis" - ] + packages = [".","memcache","postgres","redis"] revision = "b8e286a0dba8f4999042d6b258daf51b31d08938" [[projects]] @@ -171,23 +140,19 @@ [[projects]] name = "github.com/go-xorm/core" packages = ["."] - revision = "e8409d73255791843585964791443dbad877058c" + revision = "da1adaf7a28ca792961721a34e6e04945200c890" + version = "v0.5.7" [[projects]] name = "github.com/go-xorm/xorm" packages = ["."] - revision = "6687a2b4e824f4d87f2d65060ec5cb0d896dff1e" + revision = "1933dd69e294c0a26c0266637067f24dbb25770c" + version = "v0.6.4" [[projects]] branch = "master" name = "github.com/golang/protobuf" - packages = [ - "proto", - "ptypes", - "ptypes/any", - "ptypes/duration", - "ptypes/timestamp" - ] + packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"] revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237" [[projects]] @@ -256,10 +221,7 @@ [[projects]] name = "github.com/klauspost/compress" - packages = [ - "flate", - "gzip" - ] + packages = ["flate","gzip"] revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf" version = "v1.2.1" @@ -290,10 +252,7 @@ [[projects]] branch = "master" name = "github.com/lib/pq" - packages = [ - ".", - "oid" - ] + packages = [".","oid"] revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345" [[projects]] @@ -328,11 +287,7 @@ [[projects]] name = "github.com/opentracing/opentracing-go" - packages = [ - ".", - "ext", - "log" - ] + packages = [".","ext","log"] revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" version = "v1.0.2" @@ -344,12 +299,7 @@ [[projects]] name = "github.com/prometheus/client_golang" - packages = [ - "api", - "api/prometheus/v1", - "prometheus", - "prometheus/promhttp" - ] + packages = ["api","api/prometheus/v1","prometheus","prometheus/promhttp"] revision = "967789050ba94deca04a5e84cce8ad472ce313c1" version = "v0.9.0-pre1" @@ -362,22 +312,13 @@ [[projects]] branch = "master" name = "github.com/prometheus/common" - packages = [ - "expfmt", - "internal/bitbucket.org/ww/goautoneg", - "model" - ] + packages = ["expfmt","internal/bitbucket.org/ww/goautoneg","model"] revision = "89604d197083d4781071d3c65855d24ecfb0a563" [[projects]] branch = "master" name = "github.com/prometheus/procfs" - packages = [ - ".", - "internal/util", - "nfsd", - "xfs" - ] + packages = [".","internal/util","nfsd","xfs"] revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1" [[projects]] @@ -394,21 +335,13 @@ [[projects]] name = "github.com/smartystreets/assertions" - packages = [ - ".", - "internal/go-render/render", - "internal/oglematchers" - ] + packages = [".","internal/go-render/render","internal/oglematchers"] revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea" version = "1.8.1" [[projects]] name = "github.com/smartystreets/goconvey" - packages = [ - "convey", - "convey/gotest", - "convey/reporting" - ] + packages = ["convey","convey/gotest","convey/reporting"] revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" version = "1.6.3" @@ -420,21 +353,7 @@ [[projects]] name = "github.com/uber/jaeger-client-go" - packages = [ - ".", - "config", - "internal/baggage", - "internal/baggage/remote", - "internal/spanlog", - "log", - "rpcmetrics", - "thrift-gen/agent", - "thrift-gen/baggage", - "thrift-gen/jaeger", - "thrift-gen/sampling", - "thrift-gen/zipkincore", - "utils" - ] + packages = [".","config","internal/baggage","internal/baggage/remote","internal/spanlog","log","rpcmetrics","thrift-gen/agent","thrift-gen/baggage","thrift-gen/jaeger","thrift-gen/sampling","thrift-gen/zipkincore","utils"] revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7" version = "v2.11.2" @@ -446,10 +365,7 @@ [[projects]] name = "github.com/yudai/gojsondiff" - packages = [ - ".", - "formatter" - ] + packages = [".","formatter"] revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6" version = "1.0.0" @@ -462,34 +378,19 @@ [[projects]] branch = "master" name = "golang.org/x/crypto" - packages = ["pbkdf2"] + packages = ["md4","pbkdf2"] revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b" [[projects]] branch = "master" name = "golang.org/x/net" - packages = [ - "context", - "context/ctxhttp", - "http2", - "http2/hpack", - "idna", - "internal/timeseries", - "lex/httplex", - "trace" - ] + packages = ["context","context/ctxhttp","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"] revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" [[projects]] branch = "master" name = "golang.org/x/oauth2" - packages = [ - ".", - "google", - "internal", - "jws", - "jwt" - ] + packages = [".","google","internal","jws","jwt"] revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067" [[projects]] @@ -507,39 +408,12 @@ [[projects]] branch = "master" name = "golang.org/x/text" - packages = [ - "collate", - "collate/build", - "internal/colltab", - "internal/gen", - "internal/tag", - "internal/triegen", - "internal/ucd", - "language", - "secure/bidirule", - "transform", - "unicode/bidi", - "unicode/cldr", - "unicode/norm", - "unicode/rangetable" - ] + packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"] revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3" [[projects]] name = "google.golang.org/appengine" - packages = [ - ".", - "cloudsql", - "internal", - "internal/app_identity", - "internal/base", - "internal/datastore", - "internal/log", - "internal/modules", - "internal/remote_api", - "internal/urlfetch", - "urlfetch" - ] + packages = [".","cloudsql","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"] revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" @@ -551,32 +425,7 @@ [[projects]] name = "google.golang.org/grpc" - packages = [ - ".", - "balancer", - "balancer/base", - "balancer/roundrobin", - "codes", - "connectivity", - "credentials", - "encoding", - "grpclb/grpc_lb_v1/messages", - "grpclog", - "health", - "health/grpc_health_v1", - "internal", - "keepalive", - "metadata", - "naming", - "peer", - "resolver", - "resolver/dns", - "resolver/passthrough", - "stats", - "status", - "tap", - "transport" - ] + packages = [".","balancer","balancer/base","balancer/roundrobin","codes","connectivity","credentials","encoding","grpclb/grpc_lb_v1/messages","grpclog","health","health/grpc_health_v1","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"] revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef" version = "v1.9.2" @@ -598,12 +447,6 @@ revision = "567b2bfa514e796916c4747494d6ff5132a1dfce" version = "v1" -[[projects]] - branch = "v2" - name = "gopkg.in/gomail.v2" - packages = ["."] - revision = "81ebce5c23dfd25c6c67194b37d3dd3f338c98b1" - [[projects]] name = "gopkg.in/ini.v1" packages = ["."] @@ -616,6 +459,12 @@ revision = "75f2e9b42e99652f0d82b28ccb73648f44615faa" version = "v1.2.4" +[[projects]] + branch = "v2" + name = "gopkg.in/mail.v2" + packages = ["."] + revision = "5bc5c8bb07bd8d2803831fbaf8cbd630fcde2c68" + [[projects]] name = "gopkg.in/redis.v2" packages = ["."] @@ -631,6 +480,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62" + inputs-digest = "ad3c71fd3244369c313978e9e7464c7116faee764386439a17de0707a08103aa" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 22c56d29c71..a9f79c402df 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -85,13 +85,11 @@ ignored = [ [[constraint]] name = "github.com/go-xorm/core" - revision = "e8409d73255791843585964791443dbad877058c" - #version = "0.5.7" //keeping this since we would rather depend on version then commit + version = "0.5.7" [[constraint]] name = "github.com/go-xorm/xorm" - revision = "6687a2b4e824f4d87f2d65060ec5cb0d896dff1e" - #version = "0.6.4" //keeping this since we would rather depend on version then commit + version = "0.6.4" [[constraint]] name = "github.com/gorilla/websocket" @@ -174,7 +172,7 @@ ignored = [ name = "golang.org/x/sync" [[constraint]] - name = "gopkg.in/gomail.v2" + name = "gopkg.in/mail.v2" branch = "v2" [[constraint]] @@ -197,3 +195,7 @@ ignored = [ [[constraint]] branch = "master" name = "github.com/teris-io/shortid" + +[[constraint]] + name = "github.com/denisenkom/go-mssqldb" + revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" diff --git a/Makefile b/Makefile index d89718d200c..6f7beb837d8 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,14 @@ deps: deps-js build-go: go run build.go build +build-server: + go run build.go build-server + +build-cli: + go run build.go build-cli + build-js: - npm run build + yarn run build build: build-go build-js @@ -20,7 +26,7 @@ test-go: go test -v ./pkg/... test-js: - npm test + yarn test test: test-go test-js diff --git a/README.md b/README.md index 81fb1f8d42b..9a05633c391 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,6 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. ![](http://docs.grafana.org/assets/img/features/dashboard_ex1.png) -## Grafana v5 Alpha Preview -Grafana master is now v5.0 alpha. This is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5. - ## Installation Head to [docs.grafana.org](http://docs.grafana.org/installation/) and [download](https://grafana.com/get) the latest release. @@ -27,13 +24,13 @@ the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies -- Go 1.9 +- Go 1.10 - NodeJS LTS ### Building the backend ```bash go get github.com/grafana/grafana -cd ~/go/src/github.com/grafana/grafana +cd $GOPATH/src/github.com/grafana/grafana go run build.go setup go run build.go build ``` diff --git a/ROADMAP.md b/ROADMAP.md index 479c1933bc0..e7bed99489e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,25 +1,30 @@ -# Roadmap (2017-10-31) +# Roadmap (2018-02-22) 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-4 months) +### Short term (1-2 months) - - Release Grafana v5 - - Teams - - Dashboard folders - - Dashboard & folder permissions (assigned to users or groups) - - New Dashboard layout engine - - New sidemenu & nav UX +- v5.1 + - Build speed improvements & integration test execution + - Kubernetes friendly docker container + - Enterprise LDAP + - Provisioning workflow + - MSSQL datasource + +### Mid term (2-4 months) + +- v5.2 + - Azure monitor backend rewrite - Elasticsearch alerting - - React migration foundation (core components) - - Graphite 1.1 Tags Support + - First login registration view + - Backend plugins? (alert notifiers, auth) + - Crossplatform builds + - IFQL Initial support ### Long term (4 - 8 months) -- Backend plugins to support more Auth options, Alerting data sources & notifications - Alerting improvements (silence, per series tracking, etc) -- Dashboard as configuration and other automation / provisioning improvements - Progress on React migration - Change visualization (panel type) on the fly. - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) diff --git a/build.go b/build.go index d55244246ff..c38c452f61f 100644 --- a/build.go +++ b/build.go @@ -79,10 +79,18 @@ func main() { case "setup": setup() + case "build-srv": + clean() + build("grafana-server", "./pkg/cmd/grafana-server", []string{}) + case "build-cli": clean() build("grafana-cli", "./pkg/cmd/grafana-cli", []string{}) + case "build-server": + clean() + build("grafana-server", "./pkg/cmd/grafana-server", []string{}) + case "build": clean() for _, binary := range binaries { diff --git a/circle.yml b/circle.yml deleted file mode 100644 index bf013e3f5b1..00000000000 --- a/circle.yml +++ /dev/null @@ -1,57 +0,0 @@ -machine: - node: - version: 6.11.4 - python: - version: 2.7.3 - services: - - docker - environment: - GOPATH: "/home/ubuntu/.go_workspace" - ORG_PATH: "github.com/grafana" - REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.9.3.linux-amd64.tar.gz" - post: - - mkdir -p ~/download - - mkdir -p ~/docker - - test -e download/$GODIST || curl -o download/$GODIST https://storage.googleapis.com/golang/$GODIST - - sudo rm -rf /usr/local/go - - sudo tar -C /usr/local -xzf download/$GODIST - -dependencies: - cache_directories: - - "~/docker" - - "~/download" - override: - - rm -rf ${GOPATH}/src/${REPO_PATH} - - mkdir -p ${GOPATH}/src/${ORG_PATH} - - cp -r ~/grafana ${GOPATH}/src/${ORG_PATH} - pre: - - pip install awscli - - sudo apt-get update; sudo apt-get install rpm; sudo apt-get install expect - - ./scripts/build/build_container.sh - -test: - override: - - bash scripts/circle-test-frontend.sh - - bash scripts/circle-test-backend.sh - -deployment: - gh_branch: - branch: master - commands: - - ./scripts/build/deploy.sh - - ./scripts/build/sign_packages.sh - - go run build.go sha-dist - - aws s3 sync ./dist s3://$BUCKET_NAME/master - - ./scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master - - ./scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} - - go run ./scripts/build/publish.go -apiKey ${GRAFANA_COM_API_KEY} - gh_tag: - tag: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ - commands: - - ./scripts/build/deploy.sh - - ./scripts/build/sign_packages.sh - - go run build.go sha-dist - - aws s3 sync ./dist s3://$BUCKET_NAME/release - - ./scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release - - ./scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} ${CIRCLE_TAG} diff --git a/conf/defaults.ini b/conf/defaults.ini index 3766c829323..11d173d955d 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -82,6 +82,9 @@ max_idle_conn = 2 # Max conn setting default is 0 (mean not set) max_open_conn = +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +conn_max_lifetime = 14400 + # Set to true to log the sql calls and execution times. log_queries = @@ -125,6 +128,9 @@ cookie_secure = false session_life_time = 86400 gc_interval_time = 86400 +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +conn_max_lifetime = 14400 + #################################### Data proxy ########################### [dataproxy] @@ -187,9 +193,6 @@ external_snapshot_name = Publish to snapshot.raintank.io # remove expired snapshot snapshot_remove_expired = true -# remove snapshots after 90 days -snapshot_TTL_days = 90 - #################################### Dashboards ################## [dashboards] @@ -251,7 +254,7 @@ enabled = false allow_sign_up = true client_id = some_id client_secret = some_secret -scopes = user:email +scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user @@ -327,7 +330,7 @@ allow_sign_up = true enabled = false host = localhost:25 user = -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" password = cert_file = key_file = diff --git a/conf/ldap.toml b/conf/ldap.toml index ae217106cb2..166d85eabb1 100644 --- a/conf/ldap.toml +++ b/conf/ldap.toml @@ -19,7 +19,7 @@ ssl_skip_verify = false # 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 trippel quotes. Ex """#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)" diff --git a/conf/provisioning/dashboards/sample.yaml b/conf/provisioning/dashboards/sample.yaml index f0dcca9b47a..d70bd425634 100644 --- a/conf/provisioning/dashboards/sample.yaml +++ b/conf/provisioning/dashboards/sample.yaml @@ -1,5 +1,9 @@ +# # config file version +apiVersion: 1 + +#providers: # - name: 'default' -# org_id: 1 +# orgId: 1 # folder: '' # type: file # options: diff --git a/conf/provisioning/datasources/sample.yaml b/conf/provisioning/datasources/sample.yaml index c1d3c2e1fb7..877e229183d 100644 --- a/conf/provisioning/datasources/sample.yaml +++ b/conf/provisioning/datasources/sample.yaml @@ -1,7 +1,10 @@ +# # config file version +apiVersion: 1 + # # list of datasources that should be deleted from the database -#delete_datasources: +#deleteDatasources: # - name: Graphite -# org_id: 1 +# orgId: 1 # # list of datasources to insert/update depending # # on what's available in the datbase @@ -12,8 +15,8 @@ # type: graphite # # access mode. direct or proxy. Required # access: proxy -# # org id. will default to org_id 1 if not specified -# org_id: 1 +# # org id. will default to orgId 1 if not specified +# orgId: 1 # # url # url: http://localhost:8080 # # database password, if used @@ -23,22 +26,22 @@ # # database name, if used # database: # # enable/disable basic auth -# basic_auth: +# basicAuth: # # basic auth username -# basic_auth_user: +# basicAuthUser: # # basic auth password -# basic_auth_password: +# basicAuthPassword: # # enable/disable with credentials headers -# with_credentials: +# withCredentials: # # mark as default datasource. Max one per org -# is_default: +# isDefault: # # fields that will be converted to json and stored in json_data -# json_data: +# jsonData: # graphiteVersion: "1.1" # tlsAuth: true # tlsAuthWithCACert: true # # json object of data that will be encrypted. -# secure_json_data: +# secureJsonData: # tlsCACert: "..." # tlsClientCert: "..." # tlsClientKey: "..." diff --git a/conf/sample.ini b/conf/sample.ini index 784f6b7cfc9..1af5bbdb62b 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -4,10 +4,10 @@ # change # possible values : production, development -; app_mode = production +;app_mode = production # instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty -; instance_name = ${HOSTNAME} +;instance_name = ${HOSTNAME} #################################### Paths #################################### [paths] @@ -21,7 +21,7 @@ ;plugins = /var/lib/grafana/plugins # folder that contains provisioning config files that grafana will apply on startup and while running. -; provisioning = conf/provisioning +;provisioning = conf/provisioning #################################### Server #################################### [server] @@ -71,7 +71,7 @@ ;host = 127.0.0.1:3306 ;name = grafana ;user = root -# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" ;password = # Use either URL or the previous fields to configure the database @@ -90,6 +90,9 @@ # Max conn setting default is 0 (mean not set) ;max_open_conn = +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +;conn_max_lifetime = 14400 + # Set to true to log the sql calls and execution times. log_queries = @@ -121,7 +124,6 @@ log_queries = # This enables data proxy logging, default is false ;logging = false - #################################### Analytics #################################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. @@ -175,9 +177,6 @@ log_queries = # remove expired snapshot ;snapshot_remove_expired = true -# remove snapshots after 90 days -;snapshot_TTL_days = 90 - #################################### Dashboards History ################## [dashboards] # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1 @@ -326,7 +325,6 @@ log_queries = # optional settings to set different levels for specific loggers. Ex filters = sqlstore:debug ;filters = - # For "console" mode only [log.console] ;level = @@ -372,7 +370,6 @@ log_queries = # Syslog tag. By default, the process' argv[0] is used. ;tag = - #################################### Alerting ############################ [alerting] # Disable alerting engine & UI features diff --git a/docker/blocks/elastic/docker-compose.yaml b/docker/blocks/elastic/docker-compose.yaml index 193b8f252f6..2eba60f38be 100644 --- a/docker/blocks/elastic/docker-compose.yaml +++ b/docker/blocks/elastic/docker-compose.yaml @@ -6,3 +6,10 @@ - "9300:9300" volumes: - ./blocks/elastic/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml + + fake-elastic-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: elasticsearch + FD_PORT: 9200 diff --git a/docker/blocks/elastic5/docker-compose.yaml b/docker/blocks/elastic5/docker-compose.yaml index 5b12be9ada4..7148aa18c42 100644 --- a/docker/blocks/elastic5/docker-compose.yaml +++ b/docker/blocks/elastic5/docker-compose.yaml @@ -6,3 +6,10 @@ ports: - "10200:9200" - "10300:9300" + + fake-elastic5-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: elasticsearch + FD_PORT: 10200 diff --git a/docker/blocks/graphite1/big-dashboard.json b/docker/blocks/graphite1/big-dashboard.json new file mode 100644 index 00000000000..f825fb331c1 --- /dev/null +++ b/docker/blocks/graphite1/big-dashboard.json @@ -0,0 +1,1161 @@ +{ + "__inputs": [ + { + "name": "DS_GRAPHITE", + "label": "Graphite", + "description": "", + "type": "datasource", + "pluginId": "graphite", + "pluginName": "Graphite" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "5.0.0" + }, + { + "type": "datasource", + "id": "graphite", + "name": "Graphite", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "5.0.0" + } + ], + "annotations": { + "enable": false, + "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": 1, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": { + "cpu": "#E24D42", + "memory": "#1f78c1", + "statsd.fakesite.counters.session_start.desktop.count": "#6ED0E0" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE}", + "editable": true, + "fill": 3, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 4, + "interactive": true, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": true, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 2, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [ + { + "alias": "cpu", + "fill": 0, + "lines": true, + "yaxis": 2, + "zindex": 2 + }, + { + "alias": "memory", + "pointradius": 2, + "points": true + } + ], + "spaceLength": 10, + "spyable": true, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "A", + "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), 'cpu')" + }, + { + "refId": "B", + "target": "alias(statsd.fakesite.counters.session_start.desktop.count, 'memory')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "Memory / CPU", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "zerofill": true + }, + { + "aliasColors": { + "logins": "#5195ce", + "logins (-1 day)": "#447EBC", + "logins (-1 hour)": "#705da0" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE}", + "editable": true, + "fill": 1, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 3, + "interactive": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), 'logins')" + }, + { + "refId": "B", + "target": "alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), '1h'), 2), 'logins (-1 hour)')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "timezone": "browser", + "title": "logins", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "bytes", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 22, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_01.counters.requests.count, 0.4)" + } + ], + "thresholds": "200,270", + "title": "Memory", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 16, + "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": [ + { + "refId": "A", + "target": "apps.backend.backend_02.counters.requests.count" + } + ], + "thresholds": "100,270", + "title": "Sign ups", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 3 + }, + "id": 17, + "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": [ + { + "refId": "A", + "target": "apps.backend.backend_04.counters.requests.count" + } + ], + "thresholds": "100,270", + "title": "Sign outs", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 6 + }, + "id": 15, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_01.counters.requests.count, 0.7)" + } + ], + "thresholds": "100,270", + "title": "Logins", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "aliasColors": { + "web_server_01": "#badff4", + "web_server_02": "#5195ce", + "web_server_03": "#1f78c1", + "web_server_04": "#0a437c" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE}", + "editable": true, + "fill": 6, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 7 + }, + "id": 2, + "interactive": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "server requests", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 7 + }, + "id": 21, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_01.counters.requests.count, 0.8)" + } + ], + "thresholds": "200,270", + "title": "Logouts", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 9 + }, + "id": 18, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_03.counters.requests.count, 0.3)" + } + ], + "thresholds": "100,270", + "title": "Support calls", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 16, + "y": 12 + }, + "id": 26, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_01.counters.requests.count, 0.2)" + } + ], + "thresholds": "200,270", + "title": "Google hits", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 20, + "y": 12 + }, + "id": 24, + "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": [ + { + "refId": "A", + "target": "scale(apps.backend.backend_01.counters.requests.count, 0.2)" + } + ], + "thresholds": "200,270", + "title": "Google hits", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "aliasColors": { + "upper_25": "#F9E2D2", + "upper_50": "#F2C96D", + "upper_75": "#EAB839" + }, + "annotate": { + "enable": false + }, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE}", + "editable": true, + "fill": 1, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 5, + "interactive": true, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "legend_counts": true, + "lines": false, + "linewidth": 2, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, '4min', 'avg'), 4)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "client side full page load", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + } + ], + "refresh": false, + "schemaVersion": 16, + "style": "dark", + "tags": [ + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "notice": false, + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "2h", + " 6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "type": "timepicker" + }, + "timezone": "browser", + "title": "Big Dashboard", + "uid": "000000003", + "version": 1 +} \ No newline at end of file diff --git a/docker/blocks/graphite11/big-dashboard.json b/docker/blocks/graphite11/big-dashboard.json new file mode 100644 index 00000000000..dd22e287f23 --- /dev/null +++ b/docker/blocks/graphite11/big-dashboard.json @@ -0,0 +1,1179 @@ +{ + "__inputs": [ + { + "name": "DS_GRAPHITE_1.1+", + "label": "Graphite 1.1+", + "description": "", + "type": "datasource", + "pluginId": "graphite", + "pluginName": "Graphite" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "5.0.0" + }, + { + "type": "datasource", + "id": "graphite", + "name": "Graphite", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "5.0.0" + } + ], + "annotations": { + "enable": false, + "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": 1, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": { + "cpu": "#E24D42", + "memory": "#1f78c1", + "statsd.fakesite.counters.session_start.desktop.count": "#6ED0E0" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "fill": 3, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 4, + "interactive": true, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": true, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 2, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [ + { + "alias": "cpu", + "fill": 0, + "lines": true, + "yaxis": 2, + "zindex": 2 + }, + { + "alias": "memory", + "pointradius": 2, + "points": true + } + ], + "spaceLength": 10, + "spyable": true, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "C", + "target": "alias(movingAverage(scaleToSeconds(seriesByTag('name=request_status.count', 'status=code_302', 'app=fakesite', 'server=web_server_01'), 10), 20, 0), 'cpu')" + }, + { + "refCount": 0, + "refId": "A", + "target": "alias(seriesByTag('name=statsd.counters.session_start', 'app=fakesite', 'device=desktop'), 'memory')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "Memory / CPU", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "zerofill": true + }, + { + "aliasColors": { + "logins": "#5195ce", + "logins (-1 day)": "#447EBC", + "logins (-1 hour)": "#705da0" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "fill": 1, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 3, + "interactive": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "C", + "target": "alias(movingAverage(scaleToSeconds(seriesByTag('name=requests.count', 'app=fakesite', 'server=web_server_01'), 1), 2, 0), 'logins')" + }, + { + "refCount": 0, + "refId": "A", + "target": "alias(movingAverage(timeShift(scaleToSeconds(seriesByTag('name=requests.count', 'app=fakesite', 'server=web_server_01'), 1), '1h'), 2, 0), 'logins (-1 hour)')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "timezone": "browser", + "title": "logins", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "bytes", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 22, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('app=backend', 'server=backend_01', 'name=requests.count'), 0.4)", + "textEditor": false + } + ], + "thresholds": "200,270", + "title": "Memory", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 16, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "seriesByTag('app=backend', 'server=backend_02', 'name=requests.count')" + } + ], + "thresholds": "100,270", + "title": "Sign ups", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 3 + }, + "id": 17, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "seriesByTag('name=requests.count', 'app=backend', 'server=backend_04')", + "textEditor": false + } + ], + "thresholds": "100,270", + "title": "Sign outs", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 6 + }, + "id": 15, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('name=requests.count', 'app=backend', 'server=backend_01'), 0.7)", + "textEditor": false + } + ], + "thresholds": "100,270", + "title": "Logins", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "aliasColors": { + "web_server_01": "#badff4", + "web_server_02": "#5195ce", + "web_server_03": "#1f78c1", + "web_server_04": "#0a437c" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "fill": 6, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 7 + }, + "id": 2, + "interactive": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "legend_counts": true, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "B", + "target": "aliasByTags(movingAverage(scaleToSeconds(seriesByTag('name=requests.count', 'app=fakesite', 'target='), 1), 2, 0), 'server')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "server requests", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 7 + }, + "id": 21, + "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": [ + { + "hide": false, + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('name=requests.count', 'app=backend', 'server=backend_01'), 0.8)" + } + ], + "thresholds": "200,270", + "title": "Logouts", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 20, + "y": 9 + }, + "id": 18, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('name=requests.count', 'app=backend', 'server=backend_03'), 0.3)", + "textEditor": false + } + ], + "thresholds": "100,270", + "title": "Support calls", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 16, + "y": 12 + }, + "id": 26, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('name=requests.count', 'app=backend', 'server=backend_01'), 0.2)" + } + ], + "thresholds": "200,270", + "title": "Google hits", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#629e51", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 300, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 20, + "y": 12 + }, + "id": 24, + "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": [ + { + "refCount": 0, + "refId": "B", + "target": "scale(seriesByTag('name=requests.count', 'app=backend', 'server=backend_01'), 0.2)" + } + ], + "thresholds": "200,270", + "title": "Google hits", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "aliasColors": { + "upper_25": "#F9E2D2", + "upper_50": "#F2C96D", + "upper_75": "#EAB839" + }, + "annotate": { + "enable": false + }, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_GRAPHITE_1.1+}", + "editable": true, + "fill": 1, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 5, + "interactive": true, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "legend_counts": true, + "lines": false, + "linewidth": 2, + "nullPointMode": "connected", + "options": false, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [], + "spaceLength": 10, + "spyable": true, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "B", + "target": "aliasByTags(summarize(seriesByTag('name=statsd.timers.ads_timer', 'app=fakesite', 'percentile='), '4min', 'average'), 'target')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "timezone": "browser", + "title": "client side full page load", + "tooltip": { + "msResolution": false, + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "zerofill": true + } + ], + "refresh": false, + "schemaVersion": 16, + "style": "dark", + "tags": [ + "demo" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "notice": false, + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "2h", + " 6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "type": "timepicker" + }, + "timezone": "browser", + "title": "Big Dashboard", + "uid": "yNSrbLRmz", + "version": 9 +} \ No newline at end of file diff --git a/docker/blocks/graphite11/docker-compose.yaml b/docker/blocks/graphite11/docker-compose.yaml new file mode 100644 index 00000000000..4b0d837a619 --- /dev/null +++ b/docker/blocks/graphite11/docker-compose.yaml @@ -0,0 +1,18 @@ + graphite11: + image: graphiteapp/graphite-statsd + ports: + - "8180:80" + - "2103-2104:2003-2004" + - "2123-2124:2023-2024" + - "8225:8125/udp" + - "8226:8126" + + fake-graphite11-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: graphite + FD_PORT: 2103 + FD_GRAPHITE_VERSION: 1.1 + depends_on: + - graphite11 \ No newline at end of file diff --git a/docker/blocks/mssql/build/Dockerfile b/docker/blocks/mssql/build/Dockerfile new file mode 100644 index 00000000000..ac19463538c --- /dev/null +++ b/docker/blocks/mssql/build/Dockerfile @@ -0,0 +1,5 @@ +FROM microsoft/mssql-server-linux:2017-CU4 +WORKDIR /usr/setup +COPY . /usr/setup +RUN chmod +x /usr/setup/setup.sh +CMD /bin/bash ./entrypoint.sh \ No newline at end of file diff --git a/docker/blocks/mssql/build/entrypoint.sh b/docker/blocks/mssql/build/entrypoint.sh new file mode 100644 index 00000000000..de5aef5124a --- /dev/null +++ b/docker/blocks/mssql/build/entrypoint.sh @@ -0,0 +1,2 @@ +#start SQL Server and run setup script +/usr/setup/setup.sh & /opt/mssql/bin/sqlservr \ No newline at end of file diff --git a/docker/blocks/mssql/build/setup.sh b/docker/blocks/mssql/build/setup.sh new file mode 100755 index 00000000000..e1d509b042e --- /dev/null +++ b/docker/blocks/mssql/build/setup.sh @@ -0,0 +1,12 @@ +#/bin/bash + +#wait for the SQL Server to come up +sleep 15s + +cat /usr/setup/setup.sql.template | awk '{ + gsub(/%%DB%%/,"'$MSSQL_DATABASE'"); + gsub(/%%USER%%/,"'$MSSQL_USER'"); + gsub(/%%PWD%%/,"'$MSSQL_PASSWORD'") +}1' > /usr/setup/setup.sql + +/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $MSSQL_SA_PASSWORD -d master -i /usr/setup/setup.sql \ No newline at end of file diff --git a/docker/blocks/mssql/build/setup.sql.template b/docker/blocks/mssql/build/setup.sql.template new file mode 100644 index 00000000000..1746a18d241 --- /dev/null +++ b/docker/blocks/mssql/build/setup.sql.template @@ -0,0 +1,14 @@ +CREATE LOGIN %%USER%% WITH PASSWORD = '%%PWD%%' +GO + +CREATE DATABASE %%DB%%; +GO + +USE %%DB%%; +GO + +CREATE USER %%USER%% FOR LOGIN %%USER%%; +GO + +EXEC sp_addrolemember 'db_owner', '%%USER%%'; +GO diff --git a/docker/blocks/mssql/dashboard.json b/docker/blocks/mssql/dashboard.json new file mode 100644 index 00000000000..ce9aa141a75 --- /dev/null +++ b/docker/blocks/mssql/dashboard.json @@ -0,0 +1,539 @@ +{ + "__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": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "A dashboard visualizing data generated from grafana/fake-data-gen", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1520976748896, + "links": [], + "panels": [ + { + "aliasColors": { + "total avg": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "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": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total avg", + "fill": 0, + "pointradius": 3, + "points": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time,\n avg(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count' AND\n hostname IN($host)\nGROUP BY $__timeGroup(createdAt,'$summarize'), hostname\nORDER BY 1", + "refId": "A" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time,\n min(value) as value,\n 'total avg' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count'\nGROUP BY $__timeGroup(createdAt,'$summarize')\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average logins / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL}", + "fill": 2, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 8, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time,\n avg(value) as value,\n 'started' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.started'\nGROUP BY $__timeGroup(createdAt,'$summarize')\nORDER BY 1", + "refId": "A" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time,\n avg(value) as value,\n 'ended' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.ended'\nGROUP BY $__timeGroup(createdAt,'$summarize')\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average payments started/ended / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 6, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time,\n max(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'cpu' AND\n hostname IN($host)\nGROUP BY $__timeGroup(createdAt,'$summarize'), hostname\nORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Max CPU / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [], + "datasource": "${DS_MSSQL}", + "fontSize": "100%", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 4, + "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": 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": "", + "format": "table", + "rawSql": "SELECT createdAt as Time, source, datacenter, hostname, value FROM grafana_metric WHERE hostname in($host)", + "refId": "A" + } + ], + "title": "Values", + "transform": "table", + "type": "table" + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_MSSQL}", + "hide": 0, + "includeAll": false, + "label": "Datacenter", + "multi": false, + "name": "datacenter", + "options": [], + "query": "SELECT DISTINCT datacenter FROM grafana_metric", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_MSSQL}", + "hide": 0, + "includeAll": true, + "label": "Hostname", + "multi": true, + "name": "host", + "options": [], + "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "1m", + "value": "1m" + }, + "hide": 0, + "label": "Summarize", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": true, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "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": "", + "title": "Grafana Fake Data Gen - MSSQL", + "uid": "86Js1xRmk", + "version": 11 +} \ No newline at end of file diff --git a/docker/blocks/mssql/docker-compose.yaml b/docker/blocks/mssql/docker-compose.yaml new file mode 100644 index 00000000000..538908fec72 --- /dev/null +++ b/docker/blocks/mssql/docker-compose.yaml @@ -0,0 +1,19 @@ + mssql: + build: + context: blocks/mssql/build + environment: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: Password! + MSSQL_PID: Express + MSSQL_DATABASE: grafana + MSSQL_USER: grafana + MSSQL_PASSWORD: Password! + ports: + - "1433:1433" + + fake-mssql-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: mssql + FD_PORT: 1433 \ No newline at end of file diff --git a/docker/blocks/mssql_tests/dashboard.json b/docker/blocks/mssql_tests/dashboard.json new file mode 100644 index 00000000000..20e3907b48b --- /dev/null +++ b/docker/blocks/mssql_tests/dashboard.json @@ -0,0 +1,2508 @@ +{ + "__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": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + }, + { + "datasource": "${DS_MSSQL_TEST}", + "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 ", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MSSQL_TEST}", + "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 ", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MSSQL_TEST}", + "enable": false, + "hide": false, + "iconColor": "#7eb26d", + "limit": 100, + "name": "Metric Values timeEpoch macro", + "rawQuery": "SELECT \n $__timeEpoch(time), \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MSSQL_TEST}", + "enable": false, + "hide": false, + "iconColor": "#1f78c1", + "limit": 100, + "name": "Metric Values native time", + "rawQuery": "SELECT \n time, \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1521715844826, + "links": [], + "panels": [ + { + "columns": [], + "datasource": "${DS_MSSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "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": "string", + "unit": "short" + } + ], + "targets": [ + { + "alias": "", + "format": "table", + "rawSql": "SELECT * from mssql_types", + "refId": "A" + } + ], + "title": "Data types", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MSSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 32, + "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": 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": "", + "format": "table", + "rawSql": "SELECT cast(null as bigint) as time", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as bigint) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MSSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 33, + "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": 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": "", + "format": "table", + "rawSql": "SELECT cast(null as datetime) as time", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as datetime) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MSSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 34, + "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": 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": "", + "format": "table", + "rawSql": "SELECT GETDATE() as time", + "refId": "A", + "target": "" + } + ], + "title": "GETDATE() as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MSSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 35, + "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": 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": "", + "format": "table", + "rawSql": "SELECT GETUTCDATE() as time", + "refId": "A", + "target": "" + } + ], + "title": "GETUTCDATE() as time", + "transform": "table", + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 7 + }, + "id": 7, + "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 $__timeGroup(time, '5m') AS time, 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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 7 + }, + "id": 9, + "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 $__timeGroup(time, '5m', NULL) AS time, 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(NULL) 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 10, + "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 $__timeGroup(time, '5m', 10.0) AS time, 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(10.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 16, + "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 $__timeGroup(time, '$summarize') AS time, avg(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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(NULL)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 13, + "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 $__timeGroup(time, '$summarize', 100.0) AS time, 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(100.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 25 + }, + "id": 27, + "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 $__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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 25 + }, + "id": 5, + "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 $__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", + "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 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 4, + "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 $__timeEpoch(time), measurement + ' - value one' as metric, valueOne 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), measurement + ' - value two' as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 28, + "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 $__timeEpoch(time), valueOne, valueTwo FROM metric_values ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 41 + }, + "id": 19, + "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": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 41 + }, + "id": 18, + "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": true, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 49 + }, + "id": 17, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked percent", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 49 + }, + "id": 20, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked percent", + "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 + } + ] + }, + { + "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 + } + ] + }, + { + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 65 + }, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 65 + }, + "id": 15, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 73 + }, + "id": 25, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 50, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 73 + }, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 100, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 81 + }, + "id": 21, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 81 + }, + "id": 26, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 89 + }, + "id": 23, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 89 + }, + "id": 24, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + } + ], + "refresh": false, + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": "'ALL'", + "current": {}, + "datasource": "${DS_MSSQL_TEST}", + "hide": 0, + "includeAll": true, + "label": "Metric", + "multi": false, + "name": "metric", + "options": [], + "query": "SELECT DISTINCT measurement FROM metric_values", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "10m", + "value": "10m" + }, + "hide": 0, + "label": "Interval", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": true, + "text": "10m", + "value": "10m" + } + ], + "query": "1s,10s,30s,1m,5m,10m", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "2018-03-15T12:30:00.000Z", + "to": "2018-03-15T13:55:01.000Z" + }, + "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": "Microsoft SQL Server Data Source Test", + "uid": "GlAqcPgmz", + "version": 57 +} \ No newline at end of file diff --git a/docker/blocks/mssql_tests/docker-compose.yaml b/docker/blocks/mssql_tests/docker-compose.yaml new file mode 100644 index 00000000000..5da6aad82af --- /dev/null +++ b/docker/blocks/mssql_tests/docker-compose.yaml @@ -0,0 +1,12 @@ + mssqltests: + build: + context: blocks/mssql/build + environment: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: Password! + MSSQL_PID: Express + MSSQL_DATABASE: grafanatest + MSSQL_USER: grafana + MSSQL_PASSWORD: Password! + ports: + - "1433:1433" \ No newline at end of file diff --git a/docker/blocks/mysql/dashboard.json b/docker/blocks/mysql/dashboard.json new file mode 100644 index 00000000000..e2b791f82e6 --- /dev/null +++ b/docker/blocks/mysql/dashboard.json @@ -0,0 +1,549 @@ +{ + "__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": "" + }, + { + "type": "datasource", + "id": "mysql", + "name": "MySQL", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "A dashboard visualizing data generated from grafana/fake-data-gen", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1518602729468, + "links": [], + "panels": [ + { + "aliasColors": { + "total avg": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "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": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total avg", + "fill": 0, + "pointradius": 3, + "points": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "hide": false, + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count' AND\n hostname IN($host)\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n min(value) as value,\n 'total avg' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'logins.count'\nGROUP BY 1\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Average logins / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n 'started' as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.started'\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n avg(value) as value,\n 'ended' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'payment.ended'\nGROUP BY 1, 3\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Average payments started/ended / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 3, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(createdAt,'$summarize') as time_sec,\n max(value) as value,\n hostname as metric\nFROM \n grafana_metric\nWHERE\n $__timeFilter(createdAt) AND\n measurement = 'cpu' AND\n hostname IN($host)\nGROUP BY 1, 3\nORDER BY 1", + "refId": "A", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": "1h", + "title": "Max CPU / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [], + "datasource": "${DS_MYSQL}", + "fontSize": "100%", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 6, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT createdAt as Time, source, datacenter, hostname, value FROM grafana_metric WHERE hostname in($host)", + "refId": "A", + "target": "" + } + ], + "timeShift": "1h", + "title": "Values", + "transform": "table", + "type": "table" + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "fake-data-gen", + "mysql" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_MYSQL}", + "hide": 0, + "includeAll": false, + "label": "Datacenter", + "multi": false, + "name": "datacenter", + "options": [], + "query": "SELECT DISTINCT datacenter FROM grafana_metric", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_MYSQL}", + "hide": 0, + "includeAll": true, + "label": "Hostname", + "multi": true, + "name": "host", + "options": [], + "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 5, + "auto_min": "10s", + "current": { + "selected": true, + "text": "1m", + "value": "1m" + }, + "hide": 0, + "label": "Summarize", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": true, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "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": "", + "title": "Grafana Fake Data Gen - MySQL", + "uid": "DGsCac3kz", + "version": 6 +} \ No newline at end of file diff --git a/docker/blocks/mysql_tests/dashboard.json b/docker/blocks/mysql_tests/dashboard.json new file mode 100644 index 00000000000..3ab08a7da35 --- /dev/null +++ b/docker/blocks/mysql_tests/dashboard.json @@ -0,0 +1,2350 @@ +{ + "__inputs": [ + { + "name": "DS_MYSQL_TEST", + "label": "MySQL TEST", + "description": "", + "type": "datasource", + "pluginId": "mysql", + "pluginName": "MySQL" + }, + { + "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": "datasource", + "id": "mysql", + "name": "MySQL", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "5.0.0" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + }, + { + "datasource": "${DS_MYSQL_TEST}", + "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 ", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MYSQL_TEST}", + "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 ", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MYSQL_TEST}", + "enable": false, + "hide": false, + "iconColor": "#7eb26d", + "limit": 100, + "name": "Metric Values timeEpoch macro", + "rawQuery": "SELECT \n $__timeEpoch(time), \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_MYSQL_TEST}", + "enable": false, + "hide": false, + "iconColor": "#1f78c1", + "limit": 100, + "name": "Metric Values native time", + "rawQuery": "SELECT \n time, \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1521715720483, + "links": [], + "panels": [ + { + "columns": [], + "datasource": "${DS_MYSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "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": "string", + "unit": "short" + } + ], + "targets": [ + { + "alias": "", + "format": "table", + "rawSql": "SELECT * from mysql_types", + "refId": "A" + } + ], + "title": "Data types", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MYSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 32, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "time_sec", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT cast(null as unsigned integer) as time_sec", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as unsigned integer) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MYSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 33, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "time_sec", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT cast(null as datetime) as time_sec", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as datetime) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MYSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 34, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "time_sec", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT cast(NOW() as datetime) as time_sec", + "refId": "A", + "target": "" + } + ], + "title": "cast()NOW() as datetime) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_MYSQL_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 35, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "time_sec", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT NOW() as time", + "refId": "A", + "target": "" + } + ], + "title": "NOW() as time", + "transform": "table", + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 7 + }, + "id": 7, + "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 $__timeGroup(time, '5m') AS time, 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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 7 + }, + "id": 9, + "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 $__timeGroup(time, '5m', NULL) AS time, 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(NULL) 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 10, + "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 $__timeGroup(time, '5m', 10.0) AS time, 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(10.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 16, + "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 $__timeGroup(time, '$summarize') AS time, avg(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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(NULL)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 13, + "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 $__timeGroup(time, '$summarize', 100.0) AS time, 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(100.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 25 + }, + "id": 27, + "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 $__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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 25 + }, + "id": 5, + "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 $__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", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 4, + "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 $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND measurement IN($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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 28, + "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 $__time(time), valueOne, valueTwo FROM metric_values ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 41 + }, + "id": 19, + "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": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 41 + }, + "id": 18, + "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": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 49 + }, + "id": 17, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked percent", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 49 + }, + "id": 20, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked percent", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 57 + }, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MSSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 57 + }, + "id": 15, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 65 + }, + "id": 25, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 50, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 65 + }, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 100, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 73 + }, + "id": 21, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 73 + }, + "id": 26, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 81 + }, + "id": 23, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_MYSQL_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 81 + }, + "id": 24, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + } + ], + "refresh": false, + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": "", + "current": {}, + "datasource": "${DS_MYSQL_TEST}", + "hide": 0, + "includeAll": true, + "label": "Metric", + "multi": true, + "name": "metric", + "options": [], + "query": "SELECT DISTINCT measurement FROM metric_values", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "10m", + "value": "10m" + }, + "hide": 0, + "label": "Interval", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": true, + "text": "10m", + "value": "10m" + } + ], + "query": "1s,10s,30s,1m,5m,10m", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "2018-03-15T11:30:00.000Z", + "to": "2018-03-15T12:55:01.000Z" + }, + "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": "MySQL Data Source Test", + "uid": "Hmf8FDkmz", + "version": 9 +} \ No newline at end of file diff --git a/docker/blocks/postgres/dashboard.json b/docker/blocks/postgres/dashboard.json new file mode 100644 index 00000000000..77b0ceac624 --- /dev/null +++ b/docker/blocks/postgres/dashboard.json @@ -0,0 +1,547 @@ +{ + "__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": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "A dashboard visualizing data generated from grafana/fake-data-gen", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1518601837383, + "links": [], + "panels": [ + { + "aliasColors": { + "total avg": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "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": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total avg", + "fill": 0, + "pointradius": 3, + "points": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "hide": false, + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n hostname as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'logins.count' AND\n hostname IN($host)\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n min(value) as \"value\",\n 'total avg' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'logins.count'\nGROUP BY time", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average logins / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n 'started' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'payment.started'\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n avg(value) as \"value\",\n 'ended' as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'payment.ended'\nGROUP BY time, metric\nORDER BY time", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Average payments started/ended / $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": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRESQL}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 3, + "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": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT\n $__timeGroup(\"createdAt\",'$summarize'),\n max(value) as \"value\",\n hostname as \"metric\"\nFROM \n grafana_metric\nWHERE\n $__timeFilter(\"createdAt\") AND\n measurement = 'cpu' AND\n hostname IN($host)\nGROUP BY time, metric\nORDER BY time", + "refId": "A", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Max CPU / $summarize", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [], + "datasource": "${DS_POSTGRESQL}", + "fontSize": "100%", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 6, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "link": false, + "pattern": "Time", + "type": "date" + }, + { + "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": "", + "format": "table", + "rawSql": "SELECT \"createdAt\" as \"Time\", source, datacenter, hostname, value FROM grafana_metric WHERE hostname in($host)", + "refId": "A", + "target": "" + } + ], + "title": "Values", + "transform": "table", + "type": "table" + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "fake-data-gen", + "postgres" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_POSTGRESQL}", + "hide": 0, + "includeAll": false, + "label": "Datacenter", + "multi": false, + "name": "datacenter", + "options": [], + "query": "SELECT DISTINCT datacenter FROM grafana_metric", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_POSTGRESQL}", + "hide": 0, + "includeAll": true, + "label": "Hostname", + "multi": true, + "name": "host", + "options": [], + "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 5, + "auto_min": "10s", + "current": { + "text": "1m", + "value": "1m" + }, + "hide": 0, + "label": "Summarize", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": true, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "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": "", + "title": "Grafana Fake Data Gen - PostgreSQL", + "uid": "JYola5qzz", + "version": 1 +} \ No newline at end of file diff --git a/docker/blocks/postgres_tests/dashboard.json b/docker/blocks/postgres_tests/dashboard.json new file mode 100644 index 00000000000..eea95863716 --- /dev/null +++ b/docker/blocks/postgres_tests/dashboard.json @@ -0,0 +1,2324 @@ +{ + "__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": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + }, + { + "datasource": "${DS_POSTGRES_TEST}", + "enable": false, + "hide": false, + "iconColor": "#6ed0e0", + "limit": 100, + "name": "Deploys", + "rawQuery": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_POSTGRES_TEST}", + "enable": false, + "hide": false, + "iconColor": "rgba(255, 96, 96, 1)", + "limit": 100, + "name": "Tickets", + "rawQuery": "SELECT \"time_sec\" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_POSTGRES_TEST}", + "enable": false, + "hide": false, + "iconColor": "#7eb26d", + "limit": 100, + "name": "Metric Values timeEpoch macro", + "rawQuery": "SELECT \n $__timeEpoch(time), \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + }, + { + "datasource": "${DS_POSTGRES_TEST}", + "enable": false, + "hide": false, + "iconColor": "#1f78c1", + "limit": 100, + "name": "Metric Values native time", + "rawQuery": "SELECT \n time, \n measurement as text, \n '' as tags\nFROM\n metric_values \nWHERE\n $__timeFilter(time)\nORDER BY 1", + "showIn": 0, + "tags": [], + "type": "tags" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1521725946837, + "links": [], + "panels": [ + { + "columns": [], + "datasource": "${DS_POSTGRES_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 1, + "desc": false + }, + "styles": [ + { + "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": "string", + "unit": "short" + } + ], + "targets": [ + { + "alias": "", + "format": "table", + "rawSql": "SELECT * FROM postgres_types", + "refId": "A" + } + ], + "title": "Data types", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_POSTGRES_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 32, + "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": 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": "", + "format": "table", + "rawSql": "SELECT cast(null as bigint) as time", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as bigint) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_POSTGRES_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 33, + "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": 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": "", + "format": "table", + "rawSql": "SELECT cast(null as timestamp) as time", + "refId": "A", + "target": "" + } + ], + "title": "cast(null as datetime) as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_POSTGRES_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 34, + "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": 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": "", + "format": "table", + "rawSql": "SELECT localtimestamp as time", + "refId": "A", + "target": "" + } + ], + "title": "localtimestamp as time", + "transform": "table", + "type": "table" + }, + { + "columns": [], + "datasource": "${DS_POSTGRES_TEST}", + "fontSize": "100%", + "gridPos": { + "h": 3, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 35, + "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": 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": "", + "format": "table", + "rawSql": "SELECT NOW() as time", + "refId": "A", + "target": "" + } + ], + "title": "NOW() as time", + "transform": "table", + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 7 + }, + "id": 7, + "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 $__timeGroup(time, '5m'), 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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 7 + }, + "id": 9, + "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 $__timeGroup(time, '5m', NULL), 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(NULL) 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 10, + "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 $__timeGroup(time, '5m', 10.0), 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(10.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 16, + "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 $__timeGroup(time, '$summarize'), avg(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 without fill", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "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", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(NULL)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 13, + "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 $__timeGroup(time, '$summarize', 100.0), 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(100.0)", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 25 + }, + "id": 27, + "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 $__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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with 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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 25 + }, + "id": 5, + "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 $__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", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 4, + "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 $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" 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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 28, + "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 $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 41 + }, + "id": 19, + "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": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 41 + }, + "id": 18, + "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": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 49 + }, + "id": 17, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - stacked percent", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 49 + }, + "id": 20, + "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": true, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - stacked percent", + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 57 + }, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 57 + }, + "id": 15, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - series mode", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "series", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 65 + }, + "id": 25, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 50, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 65 + }, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values\nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 100, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 73 + }, + "id": 21, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 73 + }, + "id": 26, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 81 + }, + "id": 23, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "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", + "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": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "current" + ] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_POSTGRES_TEST}", + "fill": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 81 + }, + "id": 24, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": false, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeEpoch(time), \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column - histogram stacked percent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": 20, + "mode": "histogram", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "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 + } + ] + } + ], + "refresh": false, + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_POSTGRES_TEST}", + "hide": 0, + "includeAll": true, + "label": "Metric", + "multi": true, + "name": "metric", + "options": [], + "query": "SELECT DISTINCT measurement FROM metric_values", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "10m", + "value": "10m" + }, + "hide": 0, + "label": "Interval", + "name": "summarize", + "options": [ + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": true, + "text": "10m", + "value": "10m" + } + ], + "query": "1s,10s,30s,1m,5m,10m", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "2018-03-15T12:30:00.000Z", + "to": "2018-03-15T13:55:01.000Z" + }, + "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": "Postgres Data Source Test", + "uid": "vHQdlVziz", + "version": 14 +} \ No newline at end of file diff --git a/docker/blocks/prometheus/docker-compose.yaml b/docker/blocks/prometheus/docker-compose.yaml index ccb1238a179..3c304cc74ad 100644 --- a/docker/blocks/prometheus/docker-compose.yaml +++ b/docker/blocks/prometheus/docker-compose.yaml @@ -23,3 +23,9 @@ network_mode: host ports: - "9093:9093" + + prometheus-random-data: + build: blocks/prometheus_random_data + network_mode: host + ports: + - "8081:8080" diff --git a/docker/blocks/prometheus/prometheus.yml b/docker/blocks/prometheus/prometheus.yml index ae40dfdf067..0ff8c3e7100 100644 --- a/docker/blocks/prometheus/prometheus.yml +++ b/docker/blocks/prometheus/prometheus.yml @@ -25,11 +25,15 @@ scrape_configs: - job_name: 'node_exporter' static_configs: - targets: ['127.0.0.1:9100'] - + - job_name: 'fake-data-gen' static_configs: - targets: ['127.0.0.1:9091'] - + - job_name: 'grafana' static_configs: - targets: ['127.0.0.1:3000'] + + - job_name: 'prometheus-random-data' + static_configs: + - targets: ['127.0.0.1:8081'] diff --git a/docker/blocks/prometheus2/Dockerfile b/docker/blocks/prometheus2/Dockerfile index d4a9eb2d75d..03edf4c9ee2 100644 --- a/docker/blocks/prometheus2/Dockerfile +++ b/docker/blocks/prometheus2/Dockerfile @@ -1,3 +1,3 @@ -FROM prom/prometheus:v2.0.0 +FROM prom/prometheus:v2.2.0 ADD prometheus.yml /etc/prometheus/ ADD alert.rules /etc/prometheus/ diff --git a/docker/blocks/prometheus2/docker-compose.yaml b/docker/blocks/prometheus2/docker-compose.yaml new file mode 100644 index 00000000000..589df868084 --- /dev/null +++ b/docker/blocks/prometheus2/docker-compose.yaml @@ -0,0 +1,31 @@ + prometheus: + build: blocks/prometheus2 + network_mode: host + ports: + - "9090:9090" + + node_exporter: + image: prom/node-exporter + network_mode: host + ports: + - "9100:9100" + + fake-prometheus-data: + image: grafana/fake-data-gen + network_mode: host + ports: + - "9091:9091" + environment: + FD_DATASOURCE: prom + + alertmanager: + image: quay.io/prometheus/alertmanager + network_mode: host + ports: + - "9093:9093" + + prometheus-random-data: + build: blocks/prometheus_random_data + network_mode: host + ports: + - "8081:8080" diff --git a/docker/blocks/prometheus2/prometheus.yml b/docker/blocks/prometheus2/prometheus.yml index 83dda78bb3c..42592543d87 100644 --- a/docker/blocks/prometheus2/prometheus.yml +++ b/docker/blocks/prometheus2/prometheus.yml @@ -25,11 +25,15 @@ scrape_configs: - job_name: 'node_exporter' static_configs: - targets: ['127.0.0.1:9100'] - + - job_name: 'fake-data-gen' static_configs: - targets: ['127.0.0.1:9091'] - + - job_name: 'grafana' static_configs: - targets: ['127.0.0.1:3000'] + + - job_name: 'prometheus-random-data' + static_configs: + - targets: ['127.0.0.1:8081'] diff --git a/docker/blocks/prometheus_random_data/Dockerfile b/docker/blocks/prometheus_random_data/Dockerfile new file mode 100644 index 00000000000..3aad497c94d --- /dev/null +++ b/docker/blocks/prometheus_random_data/Dockerfile @@ -0,0 +1,18 @@ +# This Dockerfile builds an image for a client_golang example. + +# Builder image, where we build the example. +FROM golang:1.9.0 AS builder +# Download prometheus/client_golang/examples/random first +RUN go get github.com/prometheus/client_golang/examples/random +WORKDIR /go/src/github.com/prometheus/client_golang +WORKDIR /go/src/github.com/prometheus/client_golang/prometheus +RUN go get -d +WORKDIR /go/src/github.com/prometheus/client_golang/examples/random +RUN CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w' + +# Final image. +FROM scratch +LABEL maintainer "The Prometheus Authors " +COPY --from=builder /go/src/github.com/prometheus/client_golang/examples/random . +EXPOSE 8080 +ENTRYPOINT ["/random"] diff --git a/docs/VERSION b/docs/VERSION index b570a0ac21b..5e0a0f1d665 100644 --- a/docs/VERSION +++ b/docs/VERSION @@ -1 +1 @@ -v4.3 +v5.0 diff --git a/docs/sources/administration/permissions.md b/docs/sources/administration/permissions.md index 5796d50f1fe..e7b84a417c0 100644 --- a/docs/sources/administration/permissions.md +++ b/docs/sources/administration/permissions.md @@ -65,13 +65,46 @@ Permission levels: - **Admin**: Can edit & create dashboards and edit permissions. - **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions. +- **View**: Can only view existing dashboards/folders. + +#### Restricting Access + +The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL). + +- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything. +- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule. + +#### How Grafana Resolves Multiple Permissions - Examples + +##### Example 1 (`user1` has the Editor Role) + +Permissions for a dashboard: + +- `Everyone with Editor Role Can Edit` +- `user1 Can View` + +Result: `user1` has Edit permission as the highest permission always wins. + +##### Example 2 (`user1` has the Viewer Role and is a member of `team1`) + +Permissions for a dashboard: + +- `Everyone with Viewer Role Can View` +- `user1 Can Edit` +- `team1 Can Admin` + +Result: `user1` has Admin permission as the highest permission always wins. + +##### Example 3 + +Permissions for a dashboard: + +- `user1 Can Admin (inherited from parent folder)` +- `user1 Can Edit` + +Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins. + - **View**: Can only view existing dashboars/folders. - -#### Restricting access - -The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the -Access Control List (ACL). - - You cannot override permissions for users with **Org Admin Role** - A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule. diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index b70895cd7bc..b43ea68bcd8 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -11,11 +11,13 @@ weight = 8 # Provisioning Grafana -## Config file +In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards. But that required the service to be running before you started creating dashboards and you also needed to set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs and alerts as well. + +## Config File Checkout the [configuration](/installation/configuration) page for more information on what you can configure in `grafana.ini` -### Config file locations +### Config File Locations - Default configuration from `$WORKING_DIR/conf/defaults.ini` - Custom configuration from `$WORKING_DIR/conf/custom.ini` @@ -26,7 +28,7 @@ Checkout the [configuration](/installation/configuration) page for more informat > `/etc/grafana/grafana.ini`. This path is specified in the Grafana > init.d script using `--config` file parameter. -### Using environment variables +### Using Environment Variables All options in the configuration file (listed below) can be overridden using environment variables using the syntax: @@ -59,7 +61,7 @@ export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey
-## Configuration management tools +## Configuration Management Tools Currently we do not provide any scripts/manifests for configuring Grafana. Rather than spending time learning and creating scripts/manifests for each tool, we think our time is better spent making Grafana easier to provision. Therefore, we heavily relay on the expertise of the community. @@ -76,18 +78,23 @@ Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://gith It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`provisioning/datasources`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list. -### Running multiple Grafana instances. +### Running Multiple Grafana Instances + If you are running multiple instances of Grafana you might run into problems if they have different versions of the `datasource.yaml` configuration file. The best way to solve this problem is to add a version number to each datasource in the configuration and increase it when you update the config. Grafana will only update datasources with the same or lower version number than specified in the config. That way, old configs cannot overwrite newer configs if they restart at the same time. -### Example datasource config file +### Example Datasource Config File + ```yaml +# config file version +apiVersion: 1 + # list of datasources that should be deleted from the database -delete_datasources: +deleteDatasources: - name: Graphite - org_id: 1 + orgId: 1 # list of datasources to insert/update depending -# whats available in the datbase +# whats available in the database datasources: # name of the datasource. Required - name: Graphite @@ -95,8 +102,8 @@ datasources: type: graphite # access mode. direct or proxy. Required access: proxy - # org id. will default to org_id 1 if not specified - org_id: 1 + # org id. will default to orgId 1 if not specified + orgId: 1 # url url: http://localhost:8080 # database password, if used @@ -106,22 +113,22 @@ datasources: # database name, if used database: # enable/disable basic auth - basic_auth: + basicAuth: # basic auth username - basic_auth_user: + basicAuthUser: # basic auth password - basic_auth_password: + basicAuthPassword: # enable/disable with credentials headers - with_credentials: + withCredentials: # mark as default datasource. Max one per org - is_default: + isDefault: # fields that will be converted to json and stored in json_data - json_data: + jsonData: graphiteVersion: "1.1" tlsAuth: true tlsAuthWithCACert: true # json object of data that will be encrypted. - secure_json_data: + secureJsonData: tlsCACert: "..." tlsClientCert: "..." tlsClientKey: "..." @@ -130,18 +137,24 @@ datasources: editable: false ``` -#### Json data +#### Custom Settings per Datasource + +| Datasource | Misc | +| ---- | ---- | +| Elasticsearch | Elasticsearch uses the `database` property to configure the index for a datasource | + +#### Json Data Since not all datasources have the same configuration settings we only have the most common ones as fields. The rest should be stored as a json blob in the `json_data` field. Here are the most common settings that the core datasources use. -| Name | Type | Datasource |Description | -| ----| ---- | ---- | --- | +| Name | Type | Datasource | Description | +| ---- | ---- | ---- | ---- | | tlsAuth | boolean | *All* | Enable TLS authentication using client cert configured in secure json data | -| tlsAuthWithCACert | boolean | *All* | Enable TLS authtication using CA cert | +| tlsAuthWithCACert | boolean | *All* | Enable TLS authentication using CA cert | | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. | | graphiteVersion | string | Graphite | Graphite version | | timeInterval | string | Elastic, Influxdb & Prometheus | Lowest interval/step value that should be used for this data source | -| esVersion | string | Elastic | Elasticsearch version | +| esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) | | timeField | string | Elastic | Which field that should be used as timestamp | | interval | string | Elastic | Index date time format | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | @@ -152,8 +165,7 @@ Since not all datasources have the same configuration settings we only have the | tsdbResolution | string | OpenTsdb | Resolution | | sslmode | string | Postgre | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' | - -#### Secure Json data +#### Secure Json Data `{"authType":"keys","defaultRegion":"us-west-2","timeField":"@timestamp"}` @@ -166,6 +178,8 @@ Secure json data is a map of settings that will be encrypted with [secret key](/ | tlsClientKey | string | *All* |TLS Client key for outgoing requests | | password | string | Postgre | password | | user | string | Postgre | user | +| accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch | +| secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch | ### Dashboards @@ -174,15 +188,26 @@ It's possible to manage dashboards in Grafana by adding one or more yaml config The dashboard provider config file looks somewhat like this: ```yaml +apiVersion: 1 + +providers: - name: 'default' - org_id: 1 + orgId: 1 folder: '' type: file + disableDeletion: false + editable: false options: - folder: /var/lib/grafana/dashboards + path: /var/lib/grafana/dashboards ``` +When Grafana starts, it will update/insert all dashboards available in the configured path. Then later on poll that path and look for updated json files and insert those update/insert those into the database. + +### Reuseable Dashboard Urls + +If the dashboard in the json file contains an [uid](/reference/dashboard/#json-fields), Grafana will force insert/update on that uid. This allows you to migrate dashboards betweens Grafana instances and provisioning Grafana from configuration without breaking the urls given since the new dashboard url uses the uid as identifer. When Grafana starts, it will update/insert all dashboards available in the configured folders. If you modify the file, the dashboard will also be updated. +By default Grafana will delete dashboards in the database if the file is removed. You can disable this behavior using the `disableDeletion` setting. > **Note.** Provisioning allows you to overwrite existing dashboards > which leads to problems if you re-use settings that are supposed to be unique. diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index ae68e39c26d..bb119687750 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -20,7 +20,7 @@ to add and configure a `notification` channel (can be email, PagerDuty or other ## Notification Channel Setup -{{< imgbox max-width="40%" img="/img/docs/v43/alert_notifications_menu.png" caption="Alerting Notification Channels" >}} +{{< imgbox max-width="30%" img="/img/docs/v50/alerts_notifications_menu.png" caption="Alerting Notification Channels" >}} On the Notification Channels page hit the `New Channel` button to go the page where you can configure and setup a new Notification Channel. @@ -41,6 +41,8 @@ Grafana ships with the following set of notification types: To enable email notifications you have to setup [SMTP settings](/installation/configuration/#smtp) in the Grafana config. Email notifications will upload an image of the alert graph to an external image destination if available or fallback to attaching the image to the email. +Be aware that if you use the `local` image storage email servers and clients might not be +able to access the image. ### Slack @@ -58,6 +60,8 @@ Recipient | allows you to override the Slack recipient. Mention | make it possible to include a mention in the Slack notification sent by Grafana. Ex @here or @channel Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination. +If you are using the token for a slack bot, then you have to invite the bot to the channel you want to send notifications and add the channel to the recipient field. + ### PagerDuty To set up PagerDuty, all you have to do is to provide an API key. diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index bd5b95da856..9bbbd70641d 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -59,7 +59,7 @@ avg() OF query(A, 5m, now) IS BELOW 14 ``` - `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function. -- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes from now to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes from now to 2 minutes from now. This is useful if you want to ignore the last 2 minutes of data. +- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. - `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold. The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially. diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index e955dbb9569..f7f8138b5e9 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -87,7 +87,7 @@ Name | Description *namespaces()* | Returns a list of namespaces CloudWatch support. *metrics(namespace, [region])* | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics) *dimension_keys(namespace)* | Returns a list of dimension keys in the namespace. -*dimension_values(region, namespace, metric, dimension_key)* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric` and `dimension_key`. +*dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well. *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`. *ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`. @@ -104,6 +104,7 @@ Query | Service *dimension_values(us-east-1,AWS/Redshift,CPUUtilization,ClusterIdentifier)* | RedShift *dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS *dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3 +*dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent ## ec2_instance_attribute examples diff --git a/docs/sources/features/datasources/graphite.md b/docs/sources/features/datasources/graphite.md index 05a7df7fea8..7c4187da9ae 100644 --- a/docs/sources/features/datasources/graphite.md +++ b/docs/sources/features/datasources/graphite.md @@ -75,7 +75,7 @@ You can reference queries by the row “letter” that they’re on (similar to ## Point consolidation All Graphite metrics are consolidated so that Graphite doesn't return more data points than there are pixels in the graph. By default, -this consolidation is done using `avg` function. You can how Graphite consolidates metrics by adding the Graphite consolidateBy function. +this consolidation is done using `avg` function. You can control how Graphite consolidates metrics by adding the Graphite consolidateBy function. > *Notice* This means that legend summary values (max, min, total) cannot be all correct at the same time. They are calculated > client side by Grafana. And depending on your consolidation function only one or two can be correct at the same time. diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 15247ba5ebd..c9bb16441ca 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -93,7 +93,7 @@ queries via the Dashboard menu / Annotations view. Prometheus supports two ways to query annotations. - A regular metric query -- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/alerting/rules/#inspecting-alerts-during-runtime)) +- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/#inspecting-alerts-during-runtime)) The step option is useful to limit the number of events returned from your query. diff --git a/docs/sources/features/shortcuts.md b/docs/sources/features/shortcuts.md index caad521446e..88c645eafdf 100644 --- a/docs/sources/features/shortcuts.md +++ b/docs/sources/features/shortcuts.md @@ -8,7 +8,7 @@ weight = 7 # Keyboard shortcuts -{{< docs-imagebox img="/img/docs/v4/shortcuts.png" max-width="20rem" class="docs-image--right" >}} +{{< docs-imagebox img="/img/docs/v50/shortcuts.png" max-width="20rem" class="docs-image--right" >}} Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel by hovering over it with your mouse. With a panel focused you can simple hit `e` to toggle panel @@ -34,20 +34,20 @@ Hit `?` on your keyboard to open the shortcuts help modal. - `d` `s` Dashboard settings - `d` `v` Toggle in-active / view mode - `d` `k` Toggle kiosk mode (hides top nav) +- `d` `E` Expand all rows +- `d` `C` Collapse all rows - `mod+o` Toggle shared graph crosshair ### Focused Panel - `e` Toggle panel edit view - `v` Toggle panel fullscreen view - `p` `s` Open Panel Share Modal +- `p` `d` Duplicate Panel - `p` `r` Remove Panel -### Focused Row -- `r` `c` Collapse Row -- `r` `r` Remove Row - ### Time Range - `t` `z` Zoom out time range - `t` Move time range back - `t` Move time range forward +mod = CTRL on windows or linux and CMD key on Mac diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index 2f6f9a30def..f724504156f 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -27,36 +27,36 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. - + The image above shows you the top header for a Dashboard. 1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. -2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage Dashboard playlists. -3. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. -4. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. -5. Save dashboard: The current Dashboard will be saved with the current Dashboard name. -6. Settings: Manage Dashboard settings and features such as Templating and Annotations. +2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard or folder, Import existing Dashboards, and manage Dashboard playlists. +3. Add Panel: Adds a new panel to the current Dashboard +4. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. +5. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. +6. Save dashboard: The current Dashboard will be saved with the current Dashboard name. +7. Settings: Manage Dashboard settings and features such as Templating and Annotations. -## Dashboards, Panels, Rows, the building blocks of Grafana... +## Dashboards, Panels, the building blocks of Grafana... -Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. +Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a grid. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. - + 1. Zoom out time range 2. Time picker dropdown. Here you can access relative time range options, auto refresh options and set custom absolute time ranges. 3. Manual refresh button. Will cause all panels to refresh (fetch new data). -4. Row controls menu. Via this menu you can add panels to the row, set row height and more. -5. Dashboard panel. You edit panels by clicking the panel title. -6. Graph legend. You can change series colors, y-axis and series visibility directly from the legend. +4. Dashboard panel. You edit panels by clicking the panel title. +5. Graph legend. You can change series colors, y-axis and series visibility directly from the legend. ## Adding & Editing Graphs and Panels ![](/img/docs/v45/metrics_tab.png) -1. You add panels via row menu. The row menu is the icon to the left of each row. +1. You add panels by clicking the Add panel icon on the top menu. 2. To edit the graph you click on the graph title to open the panel menu, then `Edit`. 3. This should take you to the `Metrics` tab. In this tab you should see the editor for your default data source. @@ -64,7 +64,7 @@ When you click the `Metrics` tab, you are presented with a Query Editor that is ## Drag-and-Drop panels -You can Drag-and-Drop Panels within and between Rows. Click and hold the Panel title, and drag it to its new location. You can also easily resize panels by clicking the (-) and (+) icons. +You can Drag-and-Drop Panels by simply clicking and holding the Panel title, and drag it to its new location. You can also easily resize panels by clicking the (-) and (+) icons. ![](/img/docs/animated_gifs/drag_drop.gif) diff --git a/docs/sources/guides/whats-new-in-v2-1.md b/docs/sources/guides/whats-new-in-v2-1.md index 68da4f60226..2ad0e3356f0 100644 --- a/docs/sources/guides/whats-new-in-v2-1.md +++ b/docs/sources/guides/whats-new-in-v2-1.md @@ -3,11 +3,6 @@ title = "What's New in Grafana v2.1" description = "Feature & improvement highlights for Grafana v2.1" keywords = ["grafana", "new", "documentation", "2.1"] type = "docs" -[menu.docs] -name = "Version 2.1" -identifier = "v2.1" -parent = "whatsnew" -weight = 10 +++ # What's new in Grafana v2.1 diff --git a/docs/sources/guides/whats-new-in-v2-5.md b/docs/sources/guides/whats-new-in-v2-5.md index ff80ec1f4f4..90270ea1121 100644 --- a/docs/sources/guides/whats-new-in-v2-5.md +++ b/docs/sources/guides/whats-new-in-v2-5.md @@ -3,11 +3,6 @@ title = "What's New in Grafana v2.5" description = "Feature & improvement highlights for Grafana v2.5" keywords = ["grafana", "new", "documentation", "2.5"] type = "docs" -[menu.docs] -name = "Version 2.5" -identifier = "v2.5" -parent = "whatsnew" -weight = 9 +++ # What's new in Grafana v2.5 diff --git a/docs/sources/guides/whats-new-in-v2-6.md b/docs/sources/guides/whats-new-in-v2-6.md index 0b1e6688e60..b8996680ce6 100644 --- a/docs/sources/guides/whats-new-in-v2-6.md +++ b/docs/sources/guides/whats-new-in-v2-6.md @@ -3,11 +3,6 @@ title = "What's New in Grafana v2.6" description = "Feature & improvement highlights for Grafana v2.6" keywords = ["grafana", "new", "documentation", "2.6"] type = "docs" -[menu.docs] -name = "Version 2.6" -identifier = "v2.6" -parent = "whatsnew" -weight = 7 +++ # What's new in Grafana v2.6 diff --git a/docs/sources/guides/whats-new-in-v2.md b/docs/sources/guides/whats-new-in-v2.md index bd92128a12e..499849c8d83 100644 --- a/docs/sources/guides/whats-new-in-v2.md +++ b/docs/sources/guides/whats-new-in-v2.md @@ -3,11 +3,6 @@ title = "What's New in Grafana v2.0" description = "Feature & improvement highlights for Grafana v2.0" keywords = ["grafana", "new", "documentation", "2.0"] type = "docs" -[menu.docs] -name = "Version 2.0" -identifier = "v2.0" -parent = "whatsnew" -weight = 11 +++ # What's New in Grafana v2.0 diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md index bd960ed1694..678f4cba22a 100644 --- a/docs/sources/guides/whats-new-in-v5.md +++ b/docs/sources/guides/whats-new-in-v5.md @@ -12,8 +12,6 @@ weight = -6 # What's New in Grafana v5.0 -> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1) - This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. - [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts. diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 716246102bc..0194c69caac 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -61,7 +61,7 @@ Content-Type: application/json "client_id":"some_id", "client_secret":"************", "enabled":"false", - "scopes":"user:email", + "scopes":"user:email,read:org", "team_ids":"", "token_url":"https://github.com/login/oauth/access_token" }, diff --git a/docs/sources/http_api/dashboard.md b/docs/sources/http_api/dashboard.md index 6ddb2360e03..ea1bd7f2ef7 100644 --- a/docs/sources/http_api/dashboard.md +++ b/docs/sources/http_api/dashboard.md @@ -11,6 +11,17 @@ parent = "http_api" # Dashboard API +## Identifier (id) vs unique identifier (uid) + +The identifier (id) of a dashboard is an auto-incrementing numeric value and is only unique per Grafana install. + +The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs. +It's automatically generated if not provided when creating a dashboard. The uid allows having consistent URL's for accessing +dashboards and when syncing dashboards between multiple Grafana installs, see [dashboard provisioning](/administration/provisioning/#dashboards) +for more information. This means that changing the title of a dashboard will not break any bookmarked links to that dashboard. + +The uid can have a maximum length of 40 characters. + ## Create / Update dashboard `POST /api/dashboards/db` @@ -28,24 +39,25 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "dashboard": { "id": null, + "uid": null, "title": "Production Overview", "tags": [ "templated" ], "timezone": "browser", - "rows": [ - { - } - ], - "schemaVersion": 6, + "schemaVersion": 16, "version": 0 }, + "folderId": 0, "overwrite": false } ``` JSON Body schema: -- **dashboard** – The complete dashboard model, id = null to create a new dashboard -- **overwrite** – Set to true if you want to overwrite existing dashboard with newer version or with same dashboard title. +- **dashboard** – The complete dashboard model, id = null to create a new dashboard. +- **dashboard.id** – id = null to create a new dashboard. +- **dashboard.uid** – Optional [unique identifier](/http_api/dashboard/#identifier-id-vs-unique-identifier-uid) when creating a dashboard. uid = null will generate a new uid. +- **folderId** – The id of the folder to save the dashboard in. +- **overwrite** – Set to true if you want to overwrite existing dashboard with newer version, same dashboard title in folder or same dashboard uid. - **message** - Set a commit message for the version history. **Example Response**: @@ -56,9 +68,12 @@ Content-Type: application/json; charset=UTF-8 Content-Length: 78 { - "slug": "production-overview", - "status": "success", - "version": 1 + "id": 1, + "uid": "cIBgcSjkk", + "url": "/d/cIBgcSjkk/production-overview", + "status": "success", + "version": 1, + "slug": "production-overview" //deprecated in Grafana v5.0 } ``` @@ -67,10 +82,18 @@ Status Codes: - **200** – Created - **400** – Errors (invalid json, missing or invalid fields, etc) - **401** – Unauthorized +- **403** – Access denied - **412** – Precondition failed -The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the version that was sent). The -same status code is also used if another dashboard exists with the same title. The response body will look like this: +The **412** status code is used for explaing 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` +- A dashboard with the same name in the folder already exists, `status=name-exists` +- A dashboard with the same uid already exists, `status=name-exists` +- The dashboard belongs to plugin ``, `status=plugin-dashboard` + + The response body will have the following properties: ```http HTTP/1.1 412 Precondition Failed @@ -85,16 +108,16 @@ Content-Length: 97 In case of title already exists the `status` property will be `name-exists`. -## Get dashboard +## Get dashboard by uid -`GET /api/dashboards/db/:slug` +`GET /api/dashboards/uid/:uid` -Will return the dashboard given the dashboard slug. Slug is the url friendly version of the dashboard title. +Will return the dashboard given the dashboard unique identifier (uid). **Example Request**: ```http -GET /api/dashboards/db/production-overview HTTP/1.1 +GET /api/dashboards/uid/cIBgcSjkk HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -107,35 +130,40 @@ HTTP/1.1 200 Content-Type: application/json { - "meta": { - "isStarred": false, - "slug": "production-overview" - }, "dashboard": { - "id": null, + "id": 1, + "uid": "cIBgcSjkk", "title": "Production Overview", "tags": [ "templated" ], "timezone": "browser", - "rows": [ - { - } - ], - "schemaVersion": 6, + "schemaVersion": 16, "version": 0 + }, + "meta": { + "isStarred": false, + "url": "/d/cIBgcSjkk/production-overview", + "slug": "production-overview" //deprecated in Grafana v5.0 } } ``` -## Delete dashboard +Status Codes: -`DELETE /api/dashboards/db/:slug` +- **200** – Found +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found -The above will delete the dashboard with the specified slug. The slug is the url friendly (unique) version of the dashboard title. +## Delete dashboard by uid + +`DELETE /api/dashboards/uid/:uid` + +Will delete the dashboard given the specified unique identifier (uid). **Example Request**: ```http -DELETE /api/dashboards/db/test HTTP/1.1 +DELETE /api/dashboards/uid/cIBgcSjkk HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -147,9 +175,16 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk HTTP/1.1 200 Content-Type: application/json -{"title": "Test"} +{"title": "Production Overview"} ``` +Status Codes: + +- **200** – Deleted +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found + ## Gets the home dashboard `GET /api/dashboards/home` @@ -172,27 +207,13 @@ HTTP/1.1 200 Content-Type: application/json { - "meta": { - "isHome":true, - "canSave":false, - "canEdit":false, - "canStar":false, - "slug":"", - "expires":"0001-01-01T00:00:00Z", - "created":"0001-01-01T00:00:00Z" - }, "dashboard": { "editable":false, "hideControls":true, "nav":[ - { - "enable":false, - "type":"timepicker" - } - ], - "rows": [ { - + "enable":false, + "type":"timepicker" } ], "style":"dark", @@ -206,13 +227,21 @@ Content-Type: application/json "timezone":"browser", "title":"Home", "version":5 + }, + "meta": { + "isHome":true, + "canSave":false, + "canEdit":false, + "canStar":false, + "url":"", + "expires":"0001-01-01T00:00:00Z", + "created":"0001-01-01T00:00:00Z" } } ``` ## Tags for Dashboard - `GET /api/dashboards/tags` Get all tags of dashboards @@ -244,21 +273,24 @@ Content-Type: application/json ] ``` -## Search Dashboards +## Dashboard Search +See [Folder/Dashboard Search API](/http_api/folder_dashboard_search). -`GET /api/search/` +## Deprecated resources +Please note that these resource have been deprecated and will be removed in a future release. -Query parameters: +### Get dashboard by slug +**Deprecated starting from Grafana v5.0. Please update to use the new *Get dashboard by uid* resource instead** -- **query** – Search Query -- **tag** – Tag to use -- **starred** – Flag indicating if only starred Dashboards should be returned -- **tagcloud** - Flag indicating if a tagcloud should be returned +`GET /api/dashboards/db/:slug` + +Will return the dashboard given the dashboard slug. Slug is the url friendly version of the dashboard title. +If there exists multiple dashboards with the same slug, one of them will be returned in the response. **Example Request**: ```http -GET /api/search?query=Production%20Overview&starred=true&tag=prod HTTP/1.1 +GET /api/dashboards/db/production-overview HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -270,14 +302,74 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk HTTP/1.1 200 Content-Type: application/json -[ - { - "id":1, - "title":"Production Overview", - "uri":"db/production-overview", - "type":"dash-db", - "tags":[prod], - "isStarred":true +{ + "dashboard": { + "id": 1, + "uid": "cIBgcSjkk", + "title": "Production Overview", + "tags": [ "templated" ], + "timezone": "browser", + "schemaVersion": 16, + "version": 0 + }, + "meta": { + "isStarred": false, + "url": "/d/cIBgcSjkk/production-overview", + "slug": "production-overview" // deprecated in Grafana v5.0 } -] +} +``` + +Status Codes: + +- **200** – Found +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found + +### Delete dashboard by slug +**Deprecated starting from Grafana v5.0. Please update to use the *Delete dashboard by uid* resource instead.** + +`DELETE /api/dashboards/db/:slug` + +Will delete the dashboard given the specified slug. Slug is the url friendly version of the dashboard title. + +**Example Request**: + +```http +DELETE /api/dashboards/db/test HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"title": "Production Overview"} +``` + +Status Codes: + +- **200** – Deleted +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found +- **412** – Precondition failed + +The **412** status code is used when there exists multiple dashboards with the same slug. +The response body will look like this: + +```http +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=UTF-8 +Content-Length: 97 + +{ + "message": "Multiple dashboards with the same slug exists", + "status": "multiple-slugs-exists" +} ``` diff --git a/docs/sources/http_api/dashboard_permissions.md b/docs/sources/http_api/dashboard_permissions.md new file mode 100644 index 00000000000..26aa1550d7c --- /dev/null +++ b/docs/sources/http_api/dashboard_permissions.md @@ -0,0 +1,149 @@ ++++ +title = "Dashboard Permissions HTTP API " +description = "Grafana Dashboard Permissions HTTP API" +keywords = ["grafana", "http", "documentation", "api", "dashboard", "permission", "permissions", "acl"] +aliases = ["/http_api/dashboardpermissions/"] +type = "docs" +[menu.docs] +name = "Dashboard Permissions" +parent = "http_api" ++++ + +# Dashboard Permissions API + +This API can be used to update/get the permissions for a dashboard. + +Permissions with `dashboardId=-1` are the default permissions for users with the Viewer and Editor roles. Permissions can be set for a user, a team or a role (Viewer or Editor). Permissions cannot be set for Admins - they always have access to everything. + +The permission levels for the permission field: + +- 1 = View +- 2 = Edit +- 4 = Admin + +## Get permissions for a dashboard + +`GET /api/dashboards/id/:dashboardId/permissions` + +Gets all existing permissions for the dashboard with the given `dashboardId`. + +**Example request**: + +```http +GET /api/dashboards/id/1/permissions HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response** + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 551 + +[ + { + "id": 1, + "dashboardId": -1, + "created": "2017-06-20T02:00:00+02:00", + "updated": "2017-06-20T02:00:00+02:00", + "userId": 0, + "userLogin": "", + "userEmail": "", + "teamId": 0, + "team": "", + "role": "Viewer", + "permission": 1, + "permissionName": "View", + "uid": "", + "title": "", + "slug": "", + "isFolder": false, + "url": "" + }, + { + "id": 2, + "dashboardId": -1, + "created": "2017-06-20T02:00:00+02:00", + "updated": "2017-06-20T02:00:00+02:00", + "userId": 0, + "userLogin": "", + "userEmail": "", + "teamId": 0, + "team": "", + "role": "Editor", + "permission": 2, + "permissionName": "Edit", + "uid": "", + "title": "", + "slug": "", + "isFolder": false, + "url": "" + } +] +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Access denied +- **404** - Dashboard not found + +## Update permissions for a dashboard + +`POST /api/dashboards/id/:dashboardId/permissions` + +Updates permissions for a dashboard. This operation will remove existing permissions if they're not included in the request. + +**Example request**: + +```http +POST /api/dashboards/id/1/permissions +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + "items": [ + { + "role": "Viewer", + "permission": 1 + }, + { + "role": "Editor", + "permission": 2 + }, + { + "teamId": 1, + "permission": 1 + }, + { + "userId": 11, + "permission": 4 + } + ] +} +``` + +JSON body schema: + +- **items** - The permission items to add/update. Items that are omitted from the list will be removed. + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 35 + +{"message":"Dashboard permissions updated"} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Access denied +- **404** - Dashboard not found diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 468a812c709..364b55b0cfc 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -90,7 +90,7 @@ Content-Type: application/json ## Get a single data source by Name -`GET /api/datasources/:name` +`GET /api/datasources/name/:name` **Example Request**: diff --git a/docs/sources/http_api/folder.md b/docs/sources/http_api/folder.md new file mode 100644 index 00000000000..7ee1f737799 --- /dev/null +++ b/docs/sources/http_api/folder.md @@ -0,0 +1,317 @@ ++++ +title = "Folder HTTP API " +description = "Grafana Folder HTTP API" +keywords = ["grafana", "http", "documentation", "api", "folder"] +aliases = ["/http_api/folder/"] +type = "docs" +[menu.docs] +name = "Folder" +parent = "http_api" ++++ + +# Folder API + +## Identifier (id) vs unique identifier (uid) + +The identifier (id) of a folder is an auto-incrementing numeric value and is only unique per Grafana install. + +The unique identifier (uid) of a folder can be used for uniquely identify folders between multiple Grafana installs. It's automatically generated if not provided when creating a folder. The uid allows having consistent URL's for accessing folders and when syncing folders between multiple Grafana installs. This means that changing the title of a folder will not break any bookmarked links to that folder. + +The uid can have a maximum length of 40 characters. + + +## Get all folders + +`GET /api/folders` + +Returns all folders that the authenticated user has permission to view. + +**Example Request**: + +```http +GET /api/folders?limit=10 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, + "uid": "nErXDvCkzz", + "title": "Departmenet ABC", + "url": "/dashboards/f/nErXDvCkzz/department-abc", + "hasAcl": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "createdBy": "admin", + "created": "2018-01-31T17:43:12+01:00", + "updatedBy": "admin", + "updated": "2018-01-31T17:43:12+01:00", + "version": 1 + } +] +``` + +## Get folder by uid + +`GET /api/folders/:uid` + +Will return the folder given the folder uid. + +**Example Request**: + +```http +GET /api/folders/nErXDvCkzzh 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, + "uid": "nErXDvCkzz", + "title": "Departmenet ABC", + "url": "/dashboards/f/nErXDvCkzz/department-abc", + "hasAcl": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "createdBy": "admin", + "created": "2018-01-31T17:43:12+01:00", + "updatedBy": "admin", + "updated": "2018-01-31T17:43:12+01:00", + "version": 1 +} +``` + +Status Codes: + +- **200** – Found +- **401** – Unauthorized +- **403** – Access Denied +- **404** – Folder not found + +## Create folder + +`POST /api/folders` + +Creates a new folder. + +**Example Request**: + +```http +POST /api/folders HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "uid": "nErXDvCkzz", + "title": "Department ABC" +} +``` + +JSON Body schema: + +- **uid** – Optional [unique identifier](/http_api/folder/#identifier-id-vs-unique-identifier-uid). +- **title** – The title of the folder. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id":1, + "uid": "nErXDvCkzz", + "title": "Departmenet ABC", + "url": "/dashboards/f/nErXDvCkzz/department-abc", + "hasAcl": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "createdBy": "admin", + "created": "2018-01-31T17:43:12+01:00", + "updatedBy": "admin", + "updated": "2018-01-31T17:43:12+01:00", + "version": 1 +} +``` + +Status Codes: + +- **200** – Created +- **400** – Errors (invalid json, missing or invalid fields, etc) +- **401** – Unauthorized +- **403** – Access Denied + +## Update folder + +`PUT /api/folders/:uid` + +Updates an existing folder identified by uid. + +**Example Request**: + +```http +PUT /api/folders/nErXDvCkzz HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "title":"Department DEF", + "version": 1 +} +``` + +JSON Body schema: + +- **uid** – Provide another [unique identifier](/http_api/folder/#identifier-id-vs-unique-identifier-uid) than stored to change the unique identifier. +- **title** – The title of the folder. +- **version** – Provide the current version to be able to update the folder. Not needed if `overwrite=true`. +- **overwrite** – Set to true if you want to overwrite existing folder with newer version. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id":1, + "uid": "nErXDvCkzz", + "title": "Departmenet DEF", + "url": "/dashboards/f/nErXDvCkzz/department-def", + "hasAcl": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "createdBy": "admin", + "created": "2018-01-31T17:43:12+01:00", + "updatedBy": "admin", + "updated": "2018-01-31T17:43:12+01:00", + "version": 1 +} +``` + +Status Codes: + +- **200** – Updated +- **400** – Errors (invalid json, missing or invalid fields, etc) +- **401** – Unauthorized +- **403** – Access Denied +- **404** – Folder not found +- **412** – Precondition failed + +The **412** status code is used for explaing 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` + + The response body will have the following properties: + +```http +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=UTF-8 +Content-Length: 97 + +{ + "message": "The folder has been changed by someone else", + "status": "version-mismatch" +} +``` + +## Delete folder + +`DELETE /api/folders/:uid` + +Deletes an existing folder identified by uid together with all dashboards stored in the folder, if any. This operation cannot be reverted. + +**Example Request**: + +```http +DELETE /api/folders/nErXDvCkzz HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message":"Folder deleted" +} +``` + +Status Codes: + +- **200** – Deleted +- **401** – Unauthorized +- **403** – Access Denied +- **404** – Folder not found + +## Get folder by id + +`GET /api/folders/:id` + +Will return the folder identified by id. + +**Example Request**: + +```http +GET /api/folders/1 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, + "uid": "nErXDvCkzz", + "title": "Departmenet ABC", + "url": "/dashboards/f/nErXDvCkzz/department-abc", + "hasAcl": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "createdBy": "admin", + "created": "2018-01-31T17:43:12+01:00", + "updatedBy": "admin", + "updated": "2018-01-31T17:43:12+01:00", + "version": 1 +} +``` + +Status Codes: + +- **200** – Found +- **401** – Unauthorized +- **403** – Access Denied +- **404** – Folder not found diff --git a/docs/sources/http_api/folder_dashboard_search.md b/docs/sources/http_api/folder_dashboard_search.md new file mode 100644 index 00000000000..73b5dd90b87 --- /dev/null +++ b/docs/sources/http_api/folder_dashboard_search.md @@ -0,0 +1,98 @@ ++++ +title = "Folder/Dashboard Search HTTP API " +description = "Grafana Folder/Dashboard Search HTTP API" +keywords = ["grafana", "http", "documentation", "api", "search", "folder", "dashboard"] +aliases = ["/http_api/folder_dashboard_search/"] +type = "docs" +[menu.docs] +name = "Folder/dashboard search" +parent = "http_api" ++++ + +# Folder/Dashboard Search API + +## Search folders and dashboards + +`GET /api/search/` + +Query parameters: + +- **query** – Search Query +- **tag** – List of tags to search for +- **type** – Type to search for, `dash-folder` or `dash-db` +- **dashboardIds** – List of dashboard id's to search for +- **folderIds** – List of folder id's to search in for dashboards +- **starred** – Flag indicating if only starred Dashboards should be returned +- **limit** – Limit the number of returned results + +**Example request for retrieving folders and dashboards of the general folder**: + +```http +GET /api/search?folderIds=0&query=&starred=false HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response for retrieving folders and dashboards of the general folder**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 163, + "uid": "000000163", + "title": "Folder", + "url": "/dashboards/f/000000163/folder", + "type": "dash-folder", + "tags": [], + "isStarred": false, + "uri":"db/folder" // deprecated in Grafana v5.0 + }, + { + "id":1, + "uid": "cIBgcSjkk", + "title":"Production Overview", + "url": "/d/cIBgcSjkk/production-overview", + "type":"dash-db", + "tags":[prod], + "isStarred":true, + "uri":"db/production-overview" // deprecated in Grafana v5.0 + } +] +``` + +**Example request searching for dashboards**: + +```http +GET /api/search?query=Production%20Overview&starred=true&tag=prod HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response searching for dashboards**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id":1, + "uid": "cIBgcSjkk", + "title":"Production Overview", + "url": "/d/cIBgcSjkk/production-overview", + "type":"dash-db", + "tags":[prod], + "isStarred":true, + "folderId": 2, + "folderUid": "000000163", + "folderTitle": "Folder", + "folderUrl": "/dashboards/f/000000163/folder", + "uri":"db/production-overview" // deprecated in Grafana v5.0 + } +] +``` \ No newline at end of file diff --git a/docs/sources/http_api/folder_permissions.md b/docs/sources/http_api/folder_permissions.md new file mode 100644 index 00000000000..284ab70866f --- /dev/null +++ b/docs/sources/http_api/folder_permissions.md @@ -0,0 +1,149 @@ ++++ +title = "Folder Permissions HTTP API " +description = "Grafana Folder Permissions HTTP API" +keywords = ["grafana", "http", "documentation", "api", "folder", "permission", "permissions", "acl"] +aliases = ["/http_api/dashboardpermissions/"] +type = "docs" +[menu.docs] +name = "Folder Permissions" +parent = "http_api" ++++ + +# Folder Permissions API + +This API can be used to update/get the permissions for a folder. + +Permissions with `folderId=-1` are the default permissions for users with the Viewer and Editor roles. Permissions can be set for a user, a team or a role (Viewer or Editor). Permissions cannot be set for Admins - they always have access to everything. + +The permission levels for the permission field: + +- 1 = View +- 2 = Edit +- 4 = Admin + +## Get permissions for a folder + +`GET /api/folders/:uid/permissions` + +Gets all existing permissions for the folder with the given `uid`. + +**Example request**: + +```http +GET /api/folders/nErXDvCkzz/permissions HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response** + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 551 + +[ + { + "id": 1, + "folderId": -1, + "created": "2017-06-20T02:00:00+02:00", + "updated": "2017-06-20T02:00:00+02:00", + "userId": 0, + "userLogin": "", + "userEmail": "", + "teamId": 0, + "team": "", + "role": "Viewer", + "permission": 1, + "permissionName": "View", + "uid": "nErXDvCkzz", + "title": "", + "slug": "", + "isFolder": false, + "url": "" + }, + { + "id": 2, + "dashboardId": -1, + "created": "2017-06-20T02:00:00+02:00", + "updated": "2017-06-20T02:00:00+02:00", + "userId": 0, + "userLogin": "", + "userEmail": "", + "teamId": 0, + "team": "", + "role": "Editor", + "permission": 2, + "permissionName": "Edit", + "uid": "", + "title": "", + "slug": "", + "isFolder": false, + "url": "" + } +] +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Access denied +- **404** - Folder not found + +## Update permissions for a folder + +`POST /api/folders/:uid/permissions` + +Updates permissions for a folder. This operation will remove existing permissions if they're not included in the request. + +**Example request**: + +```http +POST /api/folders/nErXDvCkzz/permissions +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + "items": [ + { + "role": "Viewer", + "permission": 1 + }, + { + "role": "Editor", + "permission": 2 + }, + { + "teamId": 1, + "permission": 1 + }, + { + "userId": 11, + "permission": 4 + } + ] +} +``` + +JSON body schema: + +- **items** - The permission items to add/update. Items that are omitted from the list will be removed. + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 35 + +{"message":"Folder permissions updated"} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Access denied +- **404** - Dashboard not found diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index cbfe004b14c..2a74917a9fd 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -21,12 +21,17 @@ dashboards, creating users and updating data sources. * [Authentication API]({{< relref "/http_api/auth.md" >}}) * [Dashboard API]({{< relref "/http_api/dashboard.md" >}}) * [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}}) +* [Dashboard Permissions API]({{< relref "http_api/dashboard_permissions.md" >}}) +* [Folder API]({{< relref "/http_api/folder.md" >}}) +* [Folder Permissions API]({{< relref "http_api/folder_permissions.md" >}}) +* [Folder/dashboard search API]({{< relref "/http_api/folder_dashboard_search.md" >}}) * [Data Source API]({{< relref "http_api/data_source.md" >}}) * [Organisation API]({{< relref "http_api/org.md" >}}) * [Snapshot API]({{< relref "http_api/snapshot.md" >}}) * [Annotations API]({{< relref "http_api/annotations.md" >}}) * [Alerting API]({{< relref "http_api/alerting.md" >}}) * [User API]({{< relref "http_api/user.md" >}}) +* [Team API]({{< relref "http_api/team.md" >}}) * [Admin API]({{< relref "http_api/admin.md" >}}) * [Preferences API]({{< relref "http_api/preferences.md" >}}) * [Other API]({{< relref "http_api/other.md" >}}) diff --git a/docs/sources/http_api/team.md b/docs/sources/http_api/team.md new file mode 100644 index 00000000000..94ea4108481 --- /dev/null +++ b/docs/sources/http_api/team.md @@ -0,0 +1,316 @@ ++++ +title = "Team HTTP API " +description = "Grafana Team HTTP API" +keywords = ["grafana", "http", "documentation", "api", "team", "teams", "group"] +aliases = ["/http_api/team/"] +type = "docs" +[menu.docs] +name = "Teams" +parent = "http_api" ++++ + +# Team API + +This API can be used to create/update/delete Teams and to add/remove users to Teams. All actions require that the user has the Admin role for the organization. + +## Team Search With Paging + +`GET /api/teams/search?perpage=50&page=1&query=mytea` + +or + +`GET /api/teams/search?name=myteam` + +```http +GET /api/teams/search?perpage=10&page=1&query=myteam HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +### Using the query parameter + +Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. + +The `totalCount` field in the response can be used for pagination of the teams list E.g. if `totalCount` is equal to 100 teams and the `perpage` parameter is set to 10 then there are 10 pages of teams. + +The `query` parameter is optional and it will return results where the query value is contained in the `name` field. Query values with spaces need to be url encoded e.g. `query=my%20team`. + +### Using the name parameter + +The `name` parameter returns a single team if the parameter matches the `name` field. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + + "totalCount": 1, + "teams": [ + { + "id": 1, + "orgId": 1, + "name": "MyTestTeam", + "email": "", + "avatarUrl": "\/avatar\/3f49c15916554246daa714b9bd0ee398", + "memberCount": 1 + } + ], + "page": 1, + "perPage": 1000 +} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Team not found (if searching by name) + +## Get Team By Id + +`GET /api/teams/:id` + +**Example Request**: + +```http +GET /api/teams/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id": 1, + "orgId": 1, + "name": "MyTestTeam", + "email": "", + "created": "2017-12-15T10:40:45+01:00", + "updated": "2017-12-15T10:40:45+01:00" +} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Team not found + +## Add Team + +The Team `name` needs to be unique. `name` is required and `email` is optional. + +`POST /api/teams` + +**Example Request**: + +```http +POST /api/teams HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "name": "MyTestTeam", + "email": "email@test.com" +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Team created","teamId":2} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **409** - Team name is taken + +## Update Team + +There are two fields that can be updated for a team: `name` and `email`. + +`PUT /api/teams/:id` + +**Example Request**: + +```http +PUT /api/teams/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "name": "MyTestTeam", + "email": "email@test.com" +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Team updated"} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Team not found +- **409** - Team name is taken + +## Delete Team By Id + +`DELETE /api/teams/:id` + +**Example Request**: + +```http +DELETE /api/teams/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Team deleted"} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Failed to delete Team. ID not found + +## Get Team Members + +`GET /api/teams/:teamId/members` + +**Example Request**: + +```http +GET /api/teams/1/members HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "orgId": 1, + "teamId": 1, + "userId": 3, + "email": "user1@email.com", + "login": "user1", + "avatarUrl": "\/avatar\/1b3c32f6386b0185c40d359cdc733a79" + }, + { + "orgId": 1, + "teamId": 1, + "userId": 2, + "email": "user2@email.com", + "login": "user2", + "avatarUrl": "\/avatar\/cad3c68da76e45d10269e8ef02f8e73e" + } +] +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied + +## Add Team Member + +`POST /api/teams/:teamId/members` + +**Example Request**: + +```http +POST /api/teams/1/members HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "userId": 2 +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Member added to Team"} +``` + +Status Codes: + +- **200** - Ok +- **400** - User is already added to this team +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Team not found + +## Remove Member From Team + +`DELETE /api/teams/:teamId/members/:userId` + +**Example Request**: + +```http +DELETE /api/teams/2/members/3 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Team Member removed"} +``` + +Status Codes: + +- **200** - Ok +- **401** - Unauthorized +- **403** - Permission denied +- **404** - Team not found/Team member not found diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 498ab7fc8df..6169280b798 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -234,7 +234,12 @@ The maximum number of connections in the idle connection pool. ### max_open_conn The maximum number of open connections to the database. +### conn_max_lifetime + +Sets the maximum amount of time a connection may be reused. The default is 14400 (which means 14400 seconds or 4 hours). For MySQL, this setting should be shorter than the [`wait_timeout`](https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_wait_timeout) variable. + ### log_queries + Set to `true` to log the sql calls and execution times.
@@ -296,7 +301,7 @@ options are `Admin` and `Editor`. e.g. : `auto_assign_org_role = Viewer` -### viewers can edit +### viewers_can_edit Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. Defaults to `false`. @@ -354,7 +359,7 @@ enabled = true allow_sign_up = true client_id = YOUR_GITHUB_APP_CLIENT_ID client_secret = YOUR_GITHUB_APP_CLIENT_SECRET -scopes = user:email +scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user @@ -387,6 +392,7 @@ scopes = user:email,read:org team_ids = 150,300 auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token +api_url = https://api.github.com/user allow_sign_up = true ``` @@ -405,6 +411,7 @@ client_secret = YOUR_GITHUB_APP_CLIENT_SECRET scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token +api_url = https://api.github.com/user allow_sign_up = true # space-delimited organization names allowed_organizations = github google @@ -795,12 +802,9 @@ Set root url to a Grafana instance where you want to publish external snapshots ### external_snapshot_name Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io` -### remove expired snapshot +### snapshot_remove_expired Enabled to automatically remove expired snapshots -### remove snapshots after 90 days -Time to live for snapshots. - ## [external_image_storage] These options control how images should be made public so they can be shared on services like slack. @@ -878,6 +882,4 @@ Defaults to true. Set to false to disable alerting engine and hide Alerting from ### execute_alerts -### execute_alerts = true - Makes it possible to turn off alert rule execution. diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index bfc7fdc0a3d..27c0e41ac15 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,8 +15,7 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb) -Beta for Debian-based Linux | [grafana_5.0.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb) +Stable for Debian-based Linux | [grafana_5.0.4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.4_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -25,19 +24,11 @@ installation. ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.4_amd64.deb sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_4.6.3_amd64.deb +sudo dpkg -i grafana_5.0.4_amd64.deb ``` -## Install Latest Beta - -```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb -sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_5.0.0-beta1_amd64.deb - -``` ## APT Repository Add the following line to your `/etc/apt/sources.list` file. diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 2b2fb22e9fa..3ca5ba06638 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -83,7 +83,7 @@ $ docker run \ -d \ -p 3000:3000 \ --name grafana \ - grafana/grafana:4.5.2 + grafana/grafana:5.0.2 ``` ## Configuring AWS Credentials for CloudWatch Support diff --git a/docs/sources/installation/ldap.md b/docs/sources/installation/ldap.md index 8f6be6e1d8c..85501e51d85 100644 --- a/docs/sources/installation/ldap.md +++ b/docs/sources/installation/ldap.md @@ -43,7 +43,7 @@ ssl_skip_verify = false # 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 trippel quotes. Ex """#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)" diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index f0c498c819f..05192512e5a 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,8 +15,8 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm) -Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm) + Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -26,13 +26,7 @@ installation. You can install Grafana using Yum directly. ```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm -``` - -## Install Beta - -```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm ``` Or install manually using `rpm`. @@ -40,15 +34,15 @@ Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: ```bash -$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm +$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm $ sudo yum install initscripts fontconfig -$ sudo rpm -Uvh grafana-4.6.3-1.x86_64.rpm +$ sudo rpm -Uvh grafana-5.0.4-1.x86_64.rpm ``` #### On OpenSuse: ```bash -$ sudo rpm -i --nodeps grafana-4.6.3-1.x86_64.rpm +$ sudo rpm -i --nodeps grafana-5.0.4-1.x86_64.rpm ``` ## Install via YUM Repository @@ -58,7 +52,7 @@ Add the following to a new file at `/etc/yum.repos.d/grafana.repo` ```bash [grafana] name=grafana -baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch +baseurl=https://packagecloud.io/grafana/stable/el/7/$basearch repo_gpgcheck=1 enabled=1 gpgcheck=1 @@ -70,7 +64,7 @@ sslcacert=/etc/pki/tls/certs/ca-bundle.crt There is also a testing repository if you want beta or release candidates. ```bash -baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch +baseurl=https://packagecloud.io/grafana/testing/el/7/$basearch ``` Then install Grafana via the `yum` command. diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index af40c20a40b..49cdd4ca1d3 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -23,7 +23,7 @@ Before upgrading it can be a good idea to backup your Grafana database. This wil #### sqlite -If you use sqlite you only need to make a backup of you `grafana.db` file. This is usually located at `/var/lib/grafana/grafana.db` on unix system. +If you use sqlite you only need to make a backup of your `grafana.db` file. This is usually located at `/var/lib/grafana/grafana.db` on unix system. If you are unsure what database you use and where it is stored check you grafana configuration file. If you installed grafana to custom location using a binary tar/zip it is usally in `/data`. @@ -105,4 +105,7 @@ We are not aware of any issues upgrading directly from 2.x to 4.x but to be on t ## Upgrading to v5.0 The dashboard grid layout engine has changed. All dashboards will be automatically upgraded to new -positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana. +positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana. Some +external panel plugins might need to be updated to work properly. + +For more details on the new panel positioning system, [click here]({{< relref "reference/dashboard.md#panel-size-position" >}}) diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 08d234d63f9..4f8d7696b57 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -8,13 +8,11 @@ parent = "installation" weight = 3 +++ - # Installing on Windows Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip) -Latest beta package for Windows | [grafana.5.0.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.windows-x64.zip) +Latest stable package for Windows | [grafana-5.0.4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -31,9 +29,9 @@ on windows. Edit `custom.ini` and uncomment the `http_port` configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar. That port should not require extra Windows privileges. -Start Grafana by executing `grafana-server.exe`, preferably from the +Start Grafana by executing `grafana-server.exe`, located in the `bin` directory, preferably from the command line. If you want to run Grafana as windows service, download -[NSSM](https://nssm.cc/). It is very easy add Grafana as a Windows +[NSSM](https://nssm.cc/). It is very easy to add Grafana as a Windows service using that tool. Read more about the [configuration options]({{< relref "configuration.md" >}}). @@ -43,7 +41,3 @@ Read more about the [configuration options]({{< relref "configuration.md" >}}). The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). - -Copy `conf/sample.ini` to a file named `conf/custom.ini` and change the -web server port to something like 8080. The default Grafana port, 3000, -requires special privileges on Windows. diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index de118f37d46..bfc104ef522 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -54,7 +54,8 @@ Annotation events are fetched via annotation queries. To add a new annotation qu open the dashboard settings menu, then select `Annotations`. This will open the dashboard annotations settings view. To create a new annotation query hit the `New` button. -![](/img/docs/annotations/new_query.png) + +{{< docs-imagebox img="/img/docs/v50/annotation_new_query.png" max-width="600px" >}} Specify a name for the annotation query. This name is given to the toggle (checkbox) that will allow you to enable/disable showing annotation events from this query. For example you might have two diff --git a/docs/sources/reference/dashboard.md b/docs/sources/reference/dashboard.md index 13f08a1ddaf..dbc3ed8635c 100644 --- a/docs/sources/reference/dashboard.md +++ b/docs/sources/reference/dashboard.md @@ -10,7 +10,7 @@ weight = 100 # Dashboard JSON -A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from rows, panels, template variables, panel queries, etc. +A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, etc. To view the JSON of a dashboard, follow the steps mentioned below: @@ -27,6 +27,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized ```json { "id": null, + "uid": "cLV5GDCkz", "title": "New dashboard", "tags": [], "style": "dark", @@ -34,7 +35,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized "editable": true, "hideControls": false, "graphTooltip": 1, - "rows": [], + "panels": [], "time": { "from": "now-6h", "to": "now" @@ -49,7 +50,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized "annotations": { "list": [] }, - "schemaVersion": 7, + "schemaVersion": 16, "version": 0, "links": [] } @@ -58,224 +59,56 @@ Each field in the dashboard JSON is explained below with its usage: | Name | Usage | | ---- | ----- | -| **id** | unique dashboard id, an integer | +| **id** | unique numeric identifier for the dashboard. (generated by the db) | +| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) | | **title** | current title of dashboard | | **tags** | tags associated with dashboard, an array of strings | | **style** | theme of dashboard, i.e. `dark` or `light` | | **timezone** | timezone of dashboard, i.e. `utc` or `browser` | | **editable** | whether a dashboard is editable or not | -| **hideControls** | whether row controls on the left in green are hidden or not | | **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip | -| **rows** | row metadata, see [rows section](#rows) for details | | **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | | **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | | **templating** | templating metadata, see [templating section](#templating) for details | | **annotations** | annotations metadata, see [annotations section](#annotations) for details | | **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to the said schema | | **version** | version of the dashboard (integer), incremented each time the dashboard is updated | -| **links** | TODO | +| **panels** | panels array, see below for detail. | -### rows +## Panels -`rows` field consists of an array of JSON object representing each row in a dashboard, such as shown below: - -```json - "rows": [ - { - "collapse": false, - "editable": true, - "height": "200px", - "panels": [], - "title": "New row" - }, - { - "collapse": true, - "editable": true, - "height": "300px", - "panels": [], - "title": "New row" - } - ] -``` - -Usage of the fields is explained below: - -| Name | Usage | -| ---- | ----- | -| **collapse** | whether row is collapsed or not | -| **editable** | whether a row is editable or not | -| **height** | height of the row in pixels | -| **panels** | panels metadata, see [panels section](#panels) for details | -| **title** | title of row | - -#### panels - -Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel in a row. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON representing a `graph` panel type: +Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON of a text panel. ```json "panels": [ - { - "aliasColors": {}, - "bars": false, - "datasource": null, - "editable": true, - "error": false, - "fill": 0, - "grid": { - "leftLogBase": 1, - "leftMax": null, - "leftMin": null, - "rightLogBase": 1, - "rightMax": null, - "rightMin": null, - "threshold1": null, - "threshold1Color": "rgba(216, 200, 27, 0.27)", - "threshold2": null, - "threshold2Color": "rgba(234, 112, 112, 0.22)" - }, - "id": 1, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 4, - "stack": false, - "steppedLine": false, - "targets": [ - { - "aggregator": "max", - "alias": "$tag_instance_id", - "currentTagKey": "", - "currentTagValue": "", - "downsampleAggregator": "avg", - "downsampleInterval": "", - "errors": {}, - "metric": "memory.percent-used", - "refId": "A", - "shouldComputeRate": false, - "tags": { - "app": "$app", - "env": "stage", - "instance_id": "*" - } - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Memory Utilization", - "tooltip": { - "shared": true, - "value_type": "cumulative" - }, - "type": "graph", - "x-axis": true, - "y-axis": true, - "y_formats": [ - "percent", - "short" - ] - }, - { - "aliasColors": {}, - "bars": false, - "datasource": null, - "editable": true, - "error": false, - "fill": 0, - "grid": { - "leftLogBase": 1, - "leftMax": null, - "leftMin": null, - "rightLogBase": 1, - "rightMax": null, - "rightMin": null, - "threshold1": null, - "threshold1Color": "rgba(216, 200, 27, 0.27)", - "threshold2": null, - "threshold2Color": "rgba(234, 112, 112, 0.22)" - }, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 4, - "stack": false, - "steppedLine": false, - "targets": [ - { - "aggregator": "avg", - "alias": "$tag_instance_id", - "currentTagKey": "", - "currentTagValue": "", - "downsampleAggregator": "avg", - "downsampleInterval": "", - "errors": {}, - "metric": "memory.percent-cached", - "refId": "A", - "shouldComputeRate": false, - "tags": { - "app": "$app", - "env": "prod", - "instance_id": "*" - } - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Memory Cached", - "tooltip": { - "shared": true, - "value_type": "cumulative" - }, - "type": "graph", - "x-axis": true, - "y-axis": true, - "y_formats": [ - "short", - "short" - ] - }, + { + "type": "text", + "title": "Panel Title", + "gridPos": { + "x": 0, + "y": 0, + "w": 12, + "h": 9 + }, + "id": 4, + "mode": "markdown", + "content": "# title" + } ``` -Usage of each field is explained below: +### Panel size & position -| Name | Usage | -| ---- | ----- | -| TODO | TODO | +The gridPos property describes the panel size and position in grid coordinates. + +- `w` 1-24 (the width of the dashboard is divided into 24 columns) +- `h` In grid height units, each represents 30 pixels. +- `x` The x position, in same unit as `w`. +- `y` The y position, in same unit as `h`. + +The grid has a negative gravity that moves panels up if there i empty space above a panel. ### timepicker -Description: TODO - ```json "timepicker": { "collapse": false, @@ -416,7 +249,3 @@ Usage of the above mentioned fields in the templating section is explained below | **refresh** | TODO | | **regex** | TODO | | **type** | type of variable, i.e. `custom`, `query` or `interval` | - -### annotations - -TODO diff --git a/docs/sources/reference/dashboard_folders.md b/docs/sources/reference/dashboard_folders.md new file mode 100644 index 00000000000..2c287c6891b --- /dev/null +++ b/docs/sources/reference/dashboard_folders.md @@ -0,0 +1,52 @@ ++++ +title = "Dashboard Folders" +keywords = ["grafana", "dashboard", "dashboard folders", "folder", "folders", "documentation", "guide"] +type = "docs" +[menu.docs] +name = "Folders" +parent = "dashboard_features" +weight = 3 ++++ + +# Dashboard Folders + +Folders are a way to organize and group dashboards - very useful if you have a lot of dashboards or multiple teams using the same Grafana instance. + +## How To Create A Folder + +- Create a folder by using the Create Folder link in the side menu (under the create menu (+ icon)) +- Use the create Folder button on the Manage Dashboards page. +- When saving a dashboard, you can either choose a folder for the dashboard to be saved in or create a new folder + +On the Create Folder page, fill in a unique name for the folder and press Create. + +## Manage Dashboards + +{{< docs-imagebox img="/img/docs/v50/manage_dashboard_menu.png" max-width="300px" class="docs-image--right" >}} + +There is a new Manage Dashboards page where you can carry out a variety of tasks: + +- create a folder +- create a dashboard +- move dashboards into folders +- delete multiple dashboards +- navigate to a folder page (where you can set permissions for a folder and/or its dashboards) + +## Dashboard Folder Page + +You reach the dashboard folder page by clicking on the cog icon that appears when you hover +over a folder in the dashboard list in the search result or on the Manage dashboards page. + +The Dashboard Folder Page is similar to the Manage Dashboards page and is where you can carry out the following tasks: + +- Allows you to move or delete dashboards in a folder. +- Rename a folder (under the Settings tab). +- Set permissions for the folder (inherited by dashboards in the folder). + +## Permissions + +Permissions can assigned to a folder and inherited by the containing dashboards. An Access Control List (ACL) is used where +**Organization Role**, **Team** and Individual **User** can be assigned permissions. Read the + [Dashboard & Folder Permissions]({{< relref "administration/permissions.md#dashboard-folder-permissions" >}}) docs for more detail + on the permission system. + diff --git a/docs/sources/reference/export_import.md b/docs/sources/reference/export_import.md index 4c2d5faa3d3..31f32d890f6 100644 --- a/docs/sources/reference/export_import.md +++ b/docs/sources/reference/export_import.md @@ -15,9 +15,9 @@ Grafana Dashboards can easily be exported and imported, either from the UI or fr Dashboards are exported in Grafana JSON format, and contain everything you need (layout, variables, styles, data sources, queries, etc)to import the dashboard at a later time. -The export feature is accessed from the share menu. +The export feature is accessed in the share window which you open by clicking the share button in the dashboard menu. - +{{< docs-imagebox img="/img/docs/v50/export_modal.png" max-width="700px" >}} ### Making a dashboard portable @@ -31,12 +31,12 @@ the dashboard, and will also be added as an required input when the dashboard is To import a dashboard open dashboard search and then hit the import button. - +{{< docs-imagebox img="/img/docs/v50/import_step1.png" max-width="700px" >}} From here you can upload a dashboard json file, paste a [Grafana.com](https://grafana.com) dashboard url or paste dashboard json text directly into the text area. - +{{< docs-imagebox img="/img/docs/v50/import_step2.png" max-width="700px" >}} In step 2 of the import process Grafana will let you change the name of the dashboard, pick what data source you want the dashboard to use and specify any metric prefixes (if the dashboard use any). @@ -45,7 +45,7 @@ data source you want the dashboard to use and specify any metric prefixes (if th Find dashboards for common server applications at [Grafana.com/dashboards](https://grafana.com/dashboards). - +{{< docs-imagebox img="/img/docs/v50/gcom_dashboard_list.png" max-width="700px" >}} ## Import & Sharing with Grafana 2.x or 3.0 diff --git a/docs/sources/reference/playlist.md b/docs/sources/reference/playlist.md index f509ae4dc0d..5a6bf921334 100644 --- a/docs/sources/reference/playlist.md +++ b/docs/sources/reference/playlist.md @@ -16,7 +16,7 @@ Since Grafana automatically scales Dashboards to any resolution they're perfect ## Creating a Playlist -{{< docs-imagebox img="/img/docs/v3/playlist.png" max-width="25rem" class="docs-image--right">}} +{{< docs-imagebox img="/img/docs/v50/playlist.png" max-width="25rem" class="docs-image--right">}} The Playlist feature can be accessed from Grafana's sidemenu, in the Dashboard submenu. diff --git a/docs/sources/reference/search.md b/docs/sources/reference/search.md index 9fc4d47893c..1bf6fd53e52 100644 --- a/docs/sources/reference/search.md +++ b/docs/sources/reference/search.md @@ -10,22 +10,22 @@ weight = 5 # Dashboard Search -Dashboards can be searched by the dashboard name, filtered by one (or many) tags or filtered by starred status. The dashboard search is accessed through the dashboard picker, available in the dashboard top nav area. +Dashboards can be searched by the dashboard name, filtered by one (or many) tags or filtered by starred status. The dashboard search is accessed through the dashboard picker, available in the dashboard top nav area. The dashboard search can also be opened by using the shortcut `F`. - + -1. `Dashboard Picker`: The Dashboard Picker is your primary navigation tool to move between dashboards. It is present on all dashboards, and open the Dashboard Search. The dashboard picker also doubles as the title of the current dashboard. -2. `Search Bar`: The search bar allows you to enter any string and search both database and file based dashboards in real-time. -3. `Starred`: The starred link allows you to filter the list to display only starred dashboards. -4. `Tags`: The tags filter allows you to filter the list by dashboard tags. +1. `Search Bar`: The search bar allows you to enter any string and search both database and file based dashboards in real-time. +2. `Starred`: Here you find all your starred dashboards. +3. `Recent`: Here you find the latest created dashboards. +4. `Folders`: The tags filter allows you to filter the list by dashboard tags. +5. `Root`: The root contains all dashboards that are not placed in a folder. +6. `Tags`: The tags filter allows you to filter the list by dashboard tags. When using only a keyboard, you can use your keyboard arrow keys to navigate the results, hit enter to open the selected dashboard. ## Find by dashboard name - - -To search and load dashboards click the open folder icon in the header or use the shortcut `CTRL`+`F`. Begin typing any part of the desired dashboard names. Search will return results for for any partial string match in real-time, as you type. +Begin typing any part of the desired dashboard names in the search bar. Search will return results for for any partial string match in real-time, as you type. Dashboard search is: - Real-time @@ -38,21 +38,8 @@ Tags are a great way to organize your dashboards, especially as the number of da To filter the dashboard list by tag, click on any tag appearing in the right column. The list may be further filtered by clicking on additional tags: - - -Alternately, to see a list of all available tags, click the tags link in the search bar. All tags will be shown, and when a tag is selected, the dashboard search will be instantly filtered: - - +Alternately, to see a list of all available tags, click the tags dropdown menu. All tags will be shown, and when a tag is selected, the dashboard search will be instantly filtered: When using only a keyboard: `tab` to focus on the *tags* link, `▼` down arrow key to find a tag and select with the `Enter` key. -**Note**: When multiple tags are selected, Grafana will show dashboards that include **all**. - - -## Filter by Starred - -Starring is a great way to organize and find commonly used dashboards. To show only starred dashboards in the list, click the *starred* link in the search bar: - - - -When using only a keyboard: `tab` to focus on the *stars* link, `▼` down arrow key to find a tag and select with the `Enter` key. +**Note**: When multiple tags are selected, Grafana will show dashboards that include **all**. \ No newline at end of file diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index 20aea1acd2e..59c2e0345ea 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -24,7 +24,7 @@ A dashboard snapshot is an instant way to share an interactive dashboard publicl (metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded into your dashboard. Dashboard snapshots can be accessed by anyone who has the link and can reach the URL. -![](/img/docs/v4/share_panel_modal.png) +{{< docs-imagebox img="/img/docs/v50/share_panel_modal.png" max-width="700px" >}} ### Publish snapshots @@ -70,9 +70,9 @@ Below there should be an interactive Grafana graph embedded in an iframe: ### Export Panel Data -![](/img/docs/v4/export_panel_data.png) +{{< docs-imagebox img="/img/docs/v50/export_panel_data.png" max-width="500px" >}} -The submenu for a panel can be found by clicking on the title of a panel and then on the hamburger (three horizontal lines) submenu on the left of the context menu. +The submenu for a panel can be found by clicking on the title of a panel and then on the More submenu. This menu contains two options for exporting data: diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 36308adf52f..016d64d9ee9 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -1,20 +1,20 @@ +++ -title = "Templating" -keywords = ["grafana", "templating", "documentation", "guide"] +title = "Variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] type = "docs" [menu.docs] -name = "Templating" +name = "Variables" parent = "dashboard_features" weight = 1 +++ -# Templating +# Variables -Templating allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application +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 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" >}} ## What is a variable? @@ -43,7 +43,7 @@ is the set of values you can choose from. ## Adding a variable - +{{< docs-imagebox img="/img/docs/v50/variables_var_list.png" max-width="800px" >}} You add variables via Dashboard cogs menu > Templating. This opens up a list of variables and a `New` button to create a new variable. @@ -80,6 +80,73 @@ Option | Description *Regex* | Regex to filter or capture specific parts of the names return by your data source query. Optional. *Sort* | Define sort order for options in dropdown. **Disabled** means that the order of options returned by your data source query will be used. +#### Using regex to filter/modify values in the Variable dropdown + +Using the Regex Query Option, you filter the list of options returned by the Variable query or modify the options returned. + +Examples of filtering on the following list of options: + +```text +backend_01 +backend_02 +backend_03 +backend_04 +``` + +##### Filter so that only the options that end with `01` or `02` are returned: + +Regex: + +```regex +/.*[01|02]/ +``` + +Result: + +```text +backend_01 +backend_02 +``` + +##### Filter and modify the options using a regex capture group to return part of the text: + +Regex: + +```regex +/.*(01|02)/ +``` + +Result: + +```text +01 +02 +``` + +#### Filter and modify - Prometheus Example + +List of options: + +```text +up{instance="demo.robustperception.io:9090",job="prometheus"} 1 1521630638000 +up{instance="demo.robustperception.io:9093",job="alertmanager"} 1 1521630638000 +up{instance="demo.robustperception.io:9100",job="node"} 1 1521630638000 +``` + +Regex: + +```regex +/.*instance="([^"]*).*/ +``` + +Result: + +```text +demo.robustperception.io:9090 +demo.robustperception.io:9093 +demo.robustperception.io:9100 +``` + ### Query expressions The query expressions are different for each data source. @@ -107,6 +174,8 @@ Interpolating a variable with multiple values selected is tricky as it is not st is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to inform the templating interpolation engine what format to use for multiple values. +Note that the *Custom all value* option on the variable will have to be left blank for Grafana to format all values into a single string. + **Graphite**, for example, uses glob expressions. A variable with multiple values would, in this case, be interpolated as `{host1,host2,host3}` if the current variable value was *host1*, *host2* and *host3*. @@ -133,7 +202,7 @@ Option | Description *Tags query* | Data source query that should return a list of tags *Tag values query* | Data source query that should return a list of values for a specified tag key. Use `$tag` in the query to refer the currently selected tag. -![](/img/docs/v4/variable_dropdown_tags.png) +{{< docs-imagebox img="/img/docs/v50/variable_dropdown_tags.png" max-width="300px" >}} ### Interval variables diff --git a/docs/sources/reference/timerange.md b/docs/sources/reference/timerange.md index a4d6fc62336..4121ed87931 100644 --- a/docs/sources/reference/timerange.md +++ b/docs/sources/reference/timerange.md @@ -13,7 +13,7 @@ weight = 7 Grafana provides numerous ways to manage the time ranges of the data being visualized, both at the Dashboard-level and the Panel-level. - + In the top right, you have the master Dashboard time picker (it's in between the 'Zoom out' and the 'Refresh' links). @@ -39,11 +39,11 @@ Week to date | `now/w` | `now` Previous Month | `now-1M/M` | `now-1M/M` -## Dashboard-Level Time Picker Settings +## Dashboard Time Options -There are two settings available from the Dashboard Settings area, allowing customization of the auto-refresh intervals and the definition of `now`. +There are two settings available in the Dashboard Settings General tab, allowing customization of the auto-refresh intervals and the definition of `now`. - + ### Auto-Refresh Options @@ -59,11 +59,11 @@ Users often ask, [when will then be now](https://www.youtube.com/watch?v=VeZ9HhH You can override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. This allows you to show metrics from different time periods or days at the same time. - +{{< docs-imagebox img="/img/docs/v50/panel_time_override.png" max-width="500px" >}} You control these overrides in panel editor mode and the tab `Time Range`. - +{{< docs-imagebox img="/img/docs/v50/time_range_tab.png" max-width="500px" >}} When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override is always active, even when the dashboard time is absolute. diff --git a/docs/sources/tutorials/ha_setup.md b/docs/sources/tutorials/ha_setup.md index 9dd8aac4618..9ae2989f6e6 100644 --- a/docs/sources/tutorials/ha_setup.md +++ b/docs/sources/tutorials/ha_setup.md @@ -9,30 +9,38 @@ weight = 10 # How to setup Grafana for high availability -> Alerting does not support high availability yet. - Setting up Grafana for high availability is fairly simple. It comes down to two things: - * Use a shared database for multiple grafana instances. - * Consider how user sessions are stored. + 1. Use a shared database for storing dashboard, users, and other persistent data + 2. Decide how to store session data. + +
+ +
## Configure multiple servers to use the same database -First you need to do is to setup mysql or postgres on another server and configure Grafana to use that database. +First, you need to do is to setup MySQL or Postgres on another server and configure Grafana to use that database. You can find the configuration for doing that in the [[database]]({{< relref "configuration.md" >}}#database) section in the grafana config. -Grafana will now persist all long term data in the database. -It also worth considering how to setup the database for high availability but thats outside the scope of this guide. +Grafana will now persist all long term data in the database. How to configure the database for high availability is out of scope for this guide. We recommend finding an expert on for the database your using. ## User sessions -The second thing to consider is how to deal with user sessions and how to balance the load between servers. -By default Grafana stores user sessions on disk which works fine if you use `sticky sessions` in your load balancer. -Grafana also supports storing the session data in the database, redis or memcache which makes it possible to use round robin in your load balancer. -If you use mysql/postgres for session storage you first need a table to store the session data in. More details about that in [[sessions]]({{< relref "configuration.md" >}}#session) +The second thing to consider is how to deal with user sessions and how to configure your load balancer infront of Grafana. +Grafana support two says of storing session data locally on disk or in a database/cache-server. +If you want to store sessions on disk you can use `sticky sessions` in your load balanacer. If you prefer to store session data in a database/cache-server +you can use any stateless routing strategy in your load balancer (ex round robin or least connections). -For Grafana itself it doesn't really matter if you store your sessions on disk or database/redis/memcache. -But we suggest that you store the session in redis/memcache since it makes it easier to add/remote instances from the group. +### Sticky sessions +Using sticky sessions, all traffic for one user will always be sent to the same server. Which means that session related data can be +stored on disk rather than on a shared database. This is the default behavior for Grafana and if only want multiple servers for fail over this is a good solution since it requires the least amount of work. + +### Stateless sessions +You can also choose to store session data in a Redis/Memcache/Postgres/MySQL which means that the load balancer can send a user to any Grafana server without having to log in on each server. This requires a little bit more work from the operator but enables you to remove/add grafana servers without impacting the user experience. +If you use MySQL/Postgres for session storage, you first need a table to store the session data in. More details about that in [[sessions]]({{< relref "configuration.md" >}}#session) + +For Grafana itself it doesn't really matter if you store the session data on disk or database/redis/memcache. But we recommend using a database/redis/memcache since it makes it easier manage the grafana servers. ## Alerting -Currently alerting supports a limited form of high availability. Since v4.2.0 of Grafana, alert notifications are deduped when running multiple servers. This means all alerts are executed on every server but no duplicate alert notifications are sent due to the deduping logic. Proper load balancing of alerts will be introduced in the future. +Currently alerting supports a limited form of high availability. Since v4.2.0, alert notifications are deduped when running multiple servers. This means all alerts are executed on every server but alert notifications are only sent once per alert. Grafana does not support distributing the alert rule execution between servers. That might be added in the future but right now prefer to keep it simple. diff --git a/docs/versions.json b/docs/versions.json index 03cb40f0e1f..2dcc7ebe776 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,6 +1,7 @@ [ - { "version": "v5.0", "path": "/v5.0", "archived": false }, - { "version": "v4.6", "path": "/", "archived": false, "current": true }, + { "version": "v5.1", "path": "/v5.1", "archived": false }, + { "version": "v5.0", "path": "/", "archived": false, "current": true }, + { "version": "v4.6", "path": "/v4.6", "archived": true }, { "version": "v4.5", "path": "/v4.5", "archived": true }, { "version": "v4.4", "path": "/v4.4", "archived": true }, { "version": "v4.3", "path": "/v4.3", "archived": true }, diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html index 07eb32874c7..ca54acdd206 100644 --- a/emails/templates/layouts/default.html +++ b/emails/templates/layouts/default.html @@ -143,7 +143,7 @@ td[class="stack-column-center"] {

Sent by Grafana v[[.BuildVersion]] -
© 2016 Grafana and raintank +
© 2018 Grafana Labs

diff --git a/latest.json b/latest.json index a746e92c3b3..b476f44a00a 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "4.6.2", - "testing": "4.6.2" + "stable": "5.0.0", + "testing": "5.0.0" } diff --git a/package.json b/package.json index 07014ef47d4..6dcfc16b82b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-beta1", + "version": "5.1.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -118,7 +118,7 @@ "prettier --write", "git add" ], - "*.go": [ + "*pkg/**/*.go": [ "gofmt -w -s", "git add" ] @@ -150,11 +150,12 @@ "mobx-state-tree": "^1.3.1", "moment": "^2.18.1", "mousetrap": "^1.6.0", + "mousetrap-global-bind": "^1.1.0", "perfect-scrollbar": "^1.2.0", "prop-types": "^15.6.0", "react": "^16.2.0", "react-dom": "^16.2.0", - "react-grid-layout": "^0.16.2", + "react-grid-layout-grafana": "0.16.0", "react-highlight-words": "^0.10.0", "react-popper": "^0.7.5", "react-select": "^1.1.0", @@ -164,7 +165,7 @@ "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^5.4.3", "tether": "^1.4.0", - "tether-drop": "https://github.com/torkelo/drop", + "tether-drop": "https://github.com/torkelo/drop/tarball/master", "tinycolor2": "^1.4.1" } } diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index 9736cbddd6c..cbe3918bf38 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,5 +1,5 @@ #! /usr/bin/env bash -version=4.6.3 +version=5.0.2 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index ca5e7aea90c..08ba2a89dd9 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=5.0.0-beta1 -rpm_ver=5.0.0-beta1 +deb_ver=5.0.0-beta5 +rpm_ver=5.0.0-beta5 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/admin.go b/pkg/api/admin.go index d7f5a240416..52d271ce69b 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -1,15 +1,15 @@ package api import ( + "regexp" "strings" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -func AdminGetSettings(c *middleware.Context) { +func AdminGetSettings(c *m.ReqContext) { settings := make(map[string]interface{}) for _, section := range setting.Cfg.Sections() { @@ -22,6 +22,14 @@ func AdminGetSettings(c *middleware.Context) { if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config")) { value = "************" } + if strings.Contains(keyName, "url") { + var rgx = regexp.MustCompile(`.*:\/\/([^:]*):([^@]*)@.*?$`) + var subs = rgx.FindAllSubmatch([]byte(value), -1) + if subs != nil && len(subs[0]) == 3 { + value = strings.Replace(value, string(subs[0][1]), "******", 1) + value = strings.Replace(value, string(subs[0][2]), "******", 1) + } + } jsonSec[keyName] = value } @@ -30,7 +38,7 @@ func AdminGetSettings(c *middleware.Context) { c.JSON(200, settings) } -func AdminGetStats(c *middleware.Context) { +func AdminGetStats(c *m.ReqContext) { statsQuery := m.GetAdminStatsQuery{} diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 1868c589673..dc3d390dda9 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -4,12 +4,11 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) -func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { +func AdminCreateUser(c *m.ReqContext, form dtos.AdminCreateUserForm) { cmd := m.CreateUserCommand{ Login: form.Login, Email: form.Email, @@ -47,15 +46,15 @@ func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { c.JSON(200, result) } -func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPasswordForm) { - userId := c.ParamsInt64(":id") +func AdminUpdateUserPassword(c *m.ReqContext, form dtos.AdminUpdateUserPasswordForm) { + userID := c.ParamsInt64(":id") if len(form.Password) < 4 { c.JsonApiErr(400, "New password too short", nil) return } - userQuery := m.GetUserByIdQuery{Id: userId} + userQuery := m.GetUserByIdQuery{Id: userID} if err := bus.Dispatch(&userQuery); err != nil { c.JsonApiErr(500, "Could not read user from database", err) @@ -65,7 +64,7 @@ func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPas passwordHashed := util.EncodePassword(form.Password, userQuery.Result.Salt) cmd := m.ChangeUserPasswordCommand{ - UserId: userId, + UserId: userID, NewPassword: passwordHashed, } @@ -77,11 +76,11 @@ func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPas c.JsonOK("User password updated") } -func AdminUpdateUserPermissions(c *middleware.Context, form dtos.AdminUpdateUserPermissionsForm) { - userId := c.ParamsInt64(":id") +func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermissionsForm) { + userID := c.ParamsInt64(":id") cmd := m.UpdateUserPermissionsCommand{ - UserId: userId, + UserId: userID, IsGrafanaAdmin: form.IsGrafanaAdmin, } @@ -93,10 +92,10 @@ func AdminUpdateUserPermissions(c *middleware.Context, form dtos.AdminUpdateUser c.JsonOK("User permissions updated") } -func AdminDeleteUser(c *middleware.Context) { - userId := c.ParamsInt64(":id") +func AdminDeleteUser(c *m.ReqContext) { + userID := c.ParamsInt64(":id") - cmd := m.DeleteUserCommand{UserId: userId} + cmd := m.DeleteUserCommand{UserId: userID} if err := bus.Dispatch(&cmd); err != nil { c.JsonApiErr(500, "Failed to delete user", err) diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 16f5f7ceb6f..a9a3773ceb1 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -5,15 +5,14 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/guardian" ) -func ValidateOrgAlert(c *middleware.Context) { +func ValidateOrgAlert(c *m.ReqContext) { id := c.ParamsInt64(":alertId") - query := models.GetAlertByIdQuery{Id: id} + query := m.GetAlertByIdQuery{Id: id} if err := bus.Dispatch(&query); err != nil { c.JsonApiErr(404, "Alert not found", nil) @@ -26,32 +25,33 @@ func ValidateOrgAlert(c *middleware.Context) { } } -func GetAlertStatesForDashboard(c *middleware.Context) Response { - dashboardId := c.QueryInt64("dashboardId") +func GetAlertStatesForDashboard(c *m.ReqContext) Response { + dashboardID := c.QueryInt64("dashboardId") - if dashboardId == 0 { - return ApiError(400, "Missing query parameter dashboardId", nil) + if dashboardID == 0 { + return Error(400, "Missing query parameter dashboardId", nil) } - query := models.GetAlertStatesForDashboardQuery{ + query := m.GetAlertStatesForDashboardQuery{ OrgId: c.OrgId, DashboardId: c.QueryInt64("dashboardId"), } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to fetch alert states", err) + return Error(500, "Failed to fetch alert states", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } // GET /api/alerts -func GetAlerts(c *middleware.Context) Response { - query := models.GetAlertsQuery{ +func GetAlerts(c *m.ReqContext) Response { + query := m.GetAlertsQuery{ OrgId: c.OrgId, DashboardId: c.QueryInt64("dashboardId"), PanelId: c.QueryInt64("panelId"), Limit: c.QueryInt64("limit"), + User: c.SignedInUser, } states := c.QueryStrings("state") @@ -60,83 +60,20 @@ func GetAlerts(c *middleware.Context) Response { } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "List alerts failed", err) + return Error(500, "List alerts failed", err) } - alertDTOs, resp := transformToDTOs(query.Result, c) - if resp != nil { - return resp + for _, alert := range query.Result { + alert.Url = m.GetDashboardUrl(alert.DashboardUid, alert.DashboardSlug) } - return Json(200, alertDTOs) -} - -func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) { - if len(alerts) == 0 { - return []*dtos.AlertRule{}, nil - } - - dashboardIds := make([]int64, 0) - alertDTOs := make([]*dtos.AlertRule, 0) - for _, alert := range alerts { - dashboardIds = append(dashboardIds, alert.DashboardId) - alertDTOs = append(alertDTOs, &dtos.AlertRule{ - Id: alert.Id, - DashboardId: alert.DashboardId, - PanelId: alert.PanelId, - Name: alert.Name, - Message: alert.Message, - State: alert.State, - NewStateDate: alert.NewStateDate, - ExecutionError: alert.ExecutionError, - EvalData: alert.EvalData, - }) - } - - dashboardsQuery := models.GetDashboardsQuery{ - DashboardIds: dashboardIds, - } - - if err := bus.Dispatch(&dashboardsQuery); err != nil { - return nil, ApiError(500, "List alerts failed", err) - } - - //TODO: should be possible to speed this up with lookup table - for _, alert := range alertDTOs { - for _, dash := range dashboardsQuery.Result { - if alert.DashboardId == dash.Id { - alert.Url = dash.GenerateUrl() - break - } - } - } - - permissionsQuery := models.GetDashboardPermissionsForUserQuery{ - DashboardIds: dashboardIds, - OrgId: c.OrgId, - UserId: c.SignedInUser.UserId, - OrgRole: c.SignedInUser.OrgRole, - } - - if err := bus.Dispatch(&permissionsQuery); err != nil { - return nil, ApiError(500, "List alerts failed", err) - } - - for _, alert := range alertDTOs { - for _, perm := range permissionsQuery.Result { - if alert.DashboardId == perm.DashboardId { - alert.CanEdit = perm.Permission > 1 - } - } - } - - return alertDTOs, nil + return JSON(200, query.Result) } // POST /api/alerts/test -func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response { +func AlertTest(c *m.ReqContext, dto dtos.AlertTestCommand) Response { if _, idErr := dto.Dashboard.Get("id").Int64(); idErr != nil { - return ApiError(400, "The dashboard needs to be saved at least once before you can test an alert rule", nil) + return Error(400, "The dashboard needs to be saved at least once before you can test an alert rule", nil) } backendCmd := alerting.AlertTestCommand{ @@ -147,9 +84,9 @@ func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response { if err := bus.Dispatch(&backendCmd); err != nil { if validationErr, ok := err.(alerting.ValidationError); ok { - return ApiError(422, validationErr.Error(), nil) + return Error(422, validationErr.Error(), nil) } - return ApiError(500, "Failed to test rule", err) + return Error(500, "Failed to test rule", err) } res := backendCmd.Result @@ -172,30 +109,30 @@ func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response { dtoRes.TimeMs = fmt.Sprintf("%1.3fms", res.GetDurationMs()) - return Json(200, dtoRes) + return JSON(200, dtoRes) } // GET /api/alerts/:id -func GetAlert(c *middleware.Context) Response { +func GetAlert(c *m.ReqContext) Response { id := c.ParamsInt64(":alertId") - query := models.GetAlertByIdQuery{Id: id} + query := m.GetAlertByIdQuery{Id: id} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "List alerts failed", err) + return Error(500, "List alerts failed", err) } - return Json(200, &query.Result) + return JSON(200, &query.Result) } -func GetAlertNotifiers(c *middleware.Context) Response { - return Json(200, alerting.GetNotifiers()) +func GetAlertNotifiers(c *m.ReqContext) Response { + return JSON(200, alerting.GetNotifiers()) } -func GetAlertNotifications(c *middleware.Context) Response { - query := &models.GetAllAlertNotificationsQuery{OrgId: c.OrgId} +func GetAlertNotifications(c *m.ReqContext) Response { + query := &m.GetAllAlertNotificationsQuery{OrgId: c.OrgId} if err := bus.Dispatch(query); err != nil { - return ApiError(500, "Failed to get alert notifications", err) + return Error(500, "Failed to get alert notifications", err) } result := make([]*dtos.AlertNotification, 0) @@ -211,57 +148,57 @@ func GetAlertNotifications(c *middleware.Context) Response { }) } - return Json(200, result) + return JSON(200, result) } -func GetAlertNotificationById(c *middleware.Context) Response { - query := &models.GetAlertNotificationsQuery{ +func GetAlertNotificationByID(c *m.ReqContext) Response { + query := &m.GetAlertNotificationsQuery{ OrgId: c.OrgId, Id: c.ParamsInt64("notificationId"), } if err := bus.Dispatch(query); err != nil { - return ApiError(500, "Failed to get alert notifications", err) + return Error(500, "Failed to get alert notifications", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func CreateAlertNotification(c *middleware.Context, cmd models.CreateAlertNotificationCommand) Response { +func CreateAlertNotification(c *m.ReqContext, cmd m.CreateAlertNotificationCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to create alert notification", err) + return Error(500, "Failed to create alert notification", err) } - return Json(200, cmd.Result) + return JSON(200, cmd.Result) } -func UpdateAlertNotification(c *middleware.Context, cmd models.UpdateAlertNotificationCommand) Response { +func UpdateAlertNotification(c *m.ReqContext, cmd m.UpdateAlertNotificationCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update alert notification", err) + return Error(500, "Failed to update alert notification", err) } - return Json(200, cmd.Result) + return JSON(200, cmd.Result) } -func DeleteAlertNotification(c *middleware.Context) Response { - cmd := models.DeleteAlertNotificationCommand{ +func DeleteAlertNotification(c *m.ReqContext) Response { + cmd := m.DeleteAlertNotificationCommand{ OrgId: c.OrgId, Id: c.ParamsInt64("notificationId"), } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete alert notification", err) + return Error(500, "Failed to delete alert notification", err) } - return ApiSuccess("Notification deleted") + return Success("Notification deleted") } //POST /api/alert-notifications/test -func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) Response { +func NotificationTest(c *m.ReqContext, dto dtos.NotificationTestCommand) Response { cmd := &alerting.NotificationTestCommand{ Name: dto.Name, Type: dto.Type, @@ -269,74 +206,74 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R } if err := bus.Dispatch(cmd); err != nil { - if err == models.ErrSmtpNotEnabled { - return ApiError(412, err.Error(), err) + if err == m.ErrSmtpNotEnabled { + return Error(412, err.Error(), err) } - return ApiError(500, "Failed to send alert notifications", err) + return Error(500, "Failed to send alert notifications", err) } - return ApiSuccess("Test notification sent") + return Success("Test notification sent") } //POST /api/alerts/:alertId/pause -func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response { - alertId := c.ParamsInt64("alertId") +func PauseAlert(c *m.ReqContext, dto dtos.PauseAlertCommand) Response { + alertID := c.ParamsInt64("alertId") - query := models.GetAlertByIdQuery{Id: alertId} + query := m.GetAlertByIdQuery{Id: alertID} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Get Alert failed", err) + return Error(500, "Get Alert failed", err) } - guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser) + guardian := guardian.New(query.Result.DashboardId, c.OrgId, c.SignedInUser) if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { if err != nil { - return ApiError(500, "Error while checking permissions for Alert", err) + return Error(500, "Error while checking permissions for Alert", err) } - return ApiError(403, "Access denied to this dashboard and alert", nil) + return Error(403, "Access denied to this dashboard and alert", nil) } - cmd := models.PauseAlertCommand{ + cmd := m.PauseAlertCommand{ OrgId: c.OrgId, - AlertIds: []int64{alertId}, + AlertIds: []int64{alertID}, Paused: dto.Paused, } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "", err) + return Error(500, "", err) } - var response models.AlertStateType = models.AlertStatePending + var response m.AlertStateType = m.AlertStatePending pausedState := "un-paused" if cmd.Paused { - response = models.AlertStatePaused + response = m.AlertStatePaused pausedState = "paused" } result := map[string]interface{}{ - "alertId": alertId, + "alertId": alertID, "state": response, "message": "Alert " + pausedState, } - return Json(200, result) + return JSON(200, result) } //POST /api/admin/pause-all-alerts -func PauseAllAlerts(c *middleware.Context, dto dtos.PauseAllAlertsCommand) Response { - updateCmd := models.PauseAllAlertCommand{ +func PauseAllAlerts(c *m.ReqContext, dto dtos.PauseAllAlertsCommand) Response { + updateCmd := m.PauseAllAlertCommand{ Paused: dto.Paused, } if err := bus.Dispatch(&updateCmd); err != nil { - return ApiError(500, "Failed to pause alerts", err) + return Error(500, "Failed to pause alerts", err) } - var response models.AlertStateType = models.AlertStatePending + var response m.AlertStateType = m.AlertStatePending pausedState := "un paused" if updateCmd.Paused { - response = models.AlertStatePaused + response = m.AlertStatePaused pausedState = "paused" } @@ -346,5 +283,5 @@ func PauseAllAlerts(c *middleware.Context, dto dtos.PauseAllAlertsCommand) Respo "alertsAffected": updateCmd.ResultCount, } - return Json(200, result) + return JSON(200, result) } diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index 6b030053e22..9302ef7beca 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" @@ -81,7 +80,7 @@ func postAlertScenario(desc string, url string, routePattern string, role m.Role defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *middleware.Context) 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.go b/pkg/api/annotations.go index e3845520795..886913d6324 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -6,14 +6,13 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" ) -func GetAnnotations(c *middleware.Context) Response { +func GetAnnotations(c *m.ReqContext) Response { query := &annotations.ItemQuery{ From: c.QueryInt64("from") / 1000, @@ -31,7 +30,7 @@ func GetAnnotations(c *middleware.Context) Response { items, err := repo.Find(query) if err != nil { - return ApiError(500, "Failed to get annotations", err) + return Error(500, "Failed to get annotations", err) } for _, item := range items { @@ -41,7 +40,7 @@ func GetAnnotations(c *middleware.Context) Response { item.Time = item.Time * 1000 } - return Json(200, items) + return JSON(200, items) } type CreateAnnotationError struct { @@ -52,8 +51,8 @@ func (e *CreateAnnotationError) Error() string { return e.message } -func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response { - if canSave, err := canSaveByDashboardId(c, cmd.DashboardId); err != nil || !canSave { +func PostAnnotation(c *m.ReqContext, cmd dtos.PostAnnotationsCmd) Response { + if canSave, err := canSaveByDashboardID(c, cmd.DashboardId); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -61,7 +60,7 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response if cmd.Text == "" { err := &CreateAnnotationError{"text field should not be empty"} - return ApiError(500, "Failed to save annotation", err) + return Error(500, "Failed to save annotation", err) } item := annotations.Item{ @@ -80,7 +79,7 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response } if err := repo.Save(&item); err != nil { - return ApiError(500, "Failed to save annotation", err) + return Error(500, "Failed to save annotation", err) } startID := item.Id @@ -94,24 +93,24 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response } if err := repo.Update(&item); err != nil { - return ApiError(500, "Failed set regionId on annotation", err) + return Error(500, "Failed set regionId on annotation", err) } item.Id = 0 item.Epoch = cmd.TimeEnd / 1000 if err := repo.Save(&item); err != nil { - return ApiError(500, "Failed save annotation for region end time", err) + return Error(500, "Failed save annotation for region end time", err) } - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "message": "Annotation added", "id": startID, "endId": item.Id, }) } - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "message": "Annotation added", "id": startID, }) @@ -125,12 +124,12 @@ func formatGraphiteAnnotation(what string, data string) string { return text } -func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotationsCmd) Response { +func PostGraphiteAnnotation(c *m.ReqContext, cmd dtos.PostGraphiteAnnotationsCmd) Response { repo := annotations.GetRepository() if cmd.What == "" { err := &CreateAnnotationError{"what field should not be empty"} - return ApiError(500, "Failed to save Graphite annotation", err) + return Error(500, "Failed to save Graphite annotation", err) } if cmd.When == 0 { @@ -153,12 +152,12 @@ func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotati tagsArray = append(tagsArray, tagStr) } else { err := &CreateAnnotationError{"tag should be a string"} - return ApiError(500, "Failed to save Graphite annotation", err) + return Error(500, "Failed to save Graphite annotation", err) } } default: err := &CreateAnnotationError{"unsupported tags format"} - return ApiError(500, "Failed to save Graphite annotation", err) + return Error(500, "Failed to save Graphite annotation", err) } item := annotations.Item{ @@ -170,35 +169,35 @@ func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotati } if err := repo.Save(&item); err != nil { - return ApiError(500, "Failed to save Graphite annotation", err) + return Error(500, "Failed to save Graphite annotation", err) } - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "message": "Graphite annotation added", "id": item.Id, }) } -func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response { - annotationId := c.ParamsInt64(":annotationId") +func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response { + annotationID := c.ParamsInt64(":annotationId") repo := annotations.GetRepository() - if resp := canSave(c, repo, annotationId); resp != nil { + if resp := canSave(c, repo, annotationID); resp != nil { return resp } item := annotations.Item{ OrgId: c.OrgId, UserId: c.UserId, - Id: annotationId, + Id: annotationID, Epoch: cmd.Time / 1000, Text: cmd.Text, Tags: cmd.Tags, } if err := repo.Update(&item); err != nil { - return ApiError(500, "Failed to update annotation", err) + return Error(500, "Failed to update annotation", err) } if cmd.IsRegion { @@ -211,14 +210,14 @@ func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Resp itemRight.Id = 0 if err := repo.Update(&itemRight); err != nil { - return ApiError(500, "Failed to update annotation for region end time", err) + return Error(500, "Failed to update annotation for region end time", err) } } - return ApiSuccess("Annotation updated") + return Success("Annotation updated") } -func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response { +func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response { repo := annotations.GetRepository() err := repo.Delete(&annotations.DeleteParams{ @@ -228,57 +227,57 @@ func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Res }) if err != nil { - return ApiError(500, "Failed to delete annotations", err) + return Error(500, "Failed to delete annotations", err) } - return ApiSuccess("Annotations deleted") + return Success("Annotations deleted") } -func DeleteAnnotationById(c *middleware.Context) Response { +func DeleteAnnotationByID(c *m.ReqContext) Response { repo := annotations.GetRepository() - annotationId := c.ParamsInt64(":annotationId") + annotationID := c.ParamsInt64(":annotationId") - if resp := canSave(c, repo, annotationId); resp != nil { + if resp := canSave(c, repo, annotationID); resp != nil { return resp } err := repo.Delete(&annotations.DeleteParams{ - Id: annotationId, + Id: annotationID, }) if err != nil { - return ApiError(500, "Failed to delete annotation", err) + return Error(500, "Failed to delete annotation", err) } - return ApiSuccess("Annotation deleted") + return Success("Annotation deleted") } -func DeleteAnnotationRegion(c *middleware.Context) Response { +func DeleteAnnotationRegion(c *m.ReqContext) Response { repo := annotations.GetRepository() - regionId := c.ParamsInt64(":regionId") + regionID := c.ParamsInt64(":regionId") - if resp := canSave(c, repo, regionId); resp != nil { + if resp := canSave(c, repo, regionID); resp != nil { return resp } err := repo.Delete(&annotations.DeleteParams{ - RegionId: regionId, + RegionId: regionID, }) if err != nil { - return ApiError(500, "Failed to delete annotation region", err) + return Error(500, "Failed to delete annotation region", err) } - return ApiSuccess("Annotation region deleted") + return Success("Annotation region deleted") } -func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error) { - if dashboardId == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) { +func canSaveByDashboardID(c *m.ReqContext, dashboardID int64) (bool, error) { + if dashboardID == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) { return false, nil } - if dashboardId > 0 { - guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser) + if dashboardID > 0 { + guardian := guardian.New(dashboardID, c.OrgId, c.SignedInUser) if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { return false, err } @@ -287,32 +286,32 @@ func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error return true, nil } -func canSave(c *middleware.Context, repo annotations.Repository, annotationId int64) Response { - items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationId, OrgId: c.OrgId}) +func canSave(c *m.ReqContext, repo annotations.Repository, annotationID int64) Response { + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId}) if err != nil || len(items) == 0 { - return ApiError(500, "Could not find annotation to update", err) + return Error(500, "Could not find annotation to update", err) } - dashboardId := items[0].DashboardId + dashboardID := items[0].DashboardId - if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + if canSave, err := canSaveByDashboardID(c, dashboardID); err != nil || !canSave { return dashboardGuardianResponse(err) } return nil } -func canSaveByRegionId(c *middleware.Context, repo annotations.Repository, regionId int64) Response { - items, err := repo.Find(&annotations.ItemQuery{RegionId: regionId, OrgId: c.OrgId}) +func canSaveByRegionID(c *m.ReqContext, repo annotations.Repository, regionID int64) Response { + items, err := repo.Find(&annotations.ItemQuery{RegionId: regionID, OrgId: c.OrgId}) if err != nil || len(items) == 0 { - return ApiError(500, "Could not find annotation to update", err) + return Error(500, "Could not find annotation to update", err) } - dashboardId := items[0].DashboardId + dashboardID := items[0].DashboardId - if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + if canSave, err := canSaveByDashboardID(c, dashboardID); err != nil || !canSave { return dashboardGuardianResponse(err) } diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 480962d8826..9fe96245b9b 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" @@ -42,7 +41,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { - sc.handlerFunc = DeleteAnnotationById + sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 403) }) @@ -69,7 +68,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { - sc.handlerFunc = DeleteAnnotationById + sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) }) @@ -133,7 +132,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { - sc.handlerFunc = DeleteAnnotationById + sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 403) }) @@ -160,7 +159,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { - sc.handlerFunc = DeleteAnnotationById + sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) }) @@ -200,7 +199,7 @@ func postAnnotationScenario(desc string, url string, routePattern string, role m defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID @@ -223,7 +222,7 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *middleware.Context) 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 1320663f630..3c7b81e472d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -9,14 +9,14 @@ import ( ) // Register adds http routes -func (hs *HttpServer) registerRoutes() { +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) reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) - redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl() - redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl() + redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() + redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota bind := binding.Bind @@ -66,11 +66,12 @@ func (hs *HttpServer) registerRoutes() { r.Get("/plugins/:id/page/:page", reqSignedIn, Index) r.Get("/d/:uid/:slug", reqSignedIn, Index) - r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index) + r.Get("/d/:uid", reqSignedIn, Index) + r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, Index) r.Get("/dashboard/script/*", reqSignedIn, Index) r.Get("/dashboard-solo/snapshot/*", Index) r.Get("/d-solo/:uid/:slug", reqSignedIn, Index) - r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index) + r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, Index) r.Get("/dashboard-solo/script/*", reqSignedIn, Index) r.Get("/import/dashboard", reqSignedIn, Index) r.Get("/dashboards/", reqSignedIn, Index) @@ -106,10 +107,10 @@ 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/:key", reqEditorRole, DeleteDashboardSnapshot) + r.Get("/api/snapshots-delete/:key", reqEditorRole, wrap(DeleteDashboardSnapshot)) // api renew session based on remember cookie - r.Get("/api/login/ping", quota("session"), LoginApiPing) + r.Get("/api/login/ping", quota("session"), LoginAPIPing) // authed api r.Group("/api", func(apiRoute RouteRegister) { @@ -138,7 +139,7 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/users", func(usersRoute RouteRegister) { usersRoute.Get("/", wrap(SearchUsers)) usersRoute.Get("/search", wrap(SearchUsersWithPaging)) - usersRoute.Get("/:id", wrap(GetUserById)) + usersRoute.Get("/:id", wrap(GetUserByID)) usersRoute.Get("/:id/orgs", wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com usersRoute.Get("/lookup", wrap(GetUserByLoginOrEmail)) @@ -148,13 +149,13 @@ func (hs *HttpServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute RouteRegister) { - teamsRoute.Get("/:teamId", wrap(GetTeamById)) + teamsRoute.Get("/:teamId", wrap(GetTeamByID)) teamsRoute.Get("/search", wrap(SearchTeams)) - teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam)) + teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam)) teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", wrap(DeleteTeamById)) + teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID)) teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) + teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) }, reqOrgAdmin) @@ -191,10 +192,10 @@ func (hs *HttpServer) registerRoutes() { // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute RouteRegister) { - orgsRoute.Get("/", wrap(GetOrgById)) + orgsRoute.Get("/", wrap(GetOrgByID)) orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrg)) orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddress)) - orgsRoute.Delete("/", wrap(DeleteOrgById)) + 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)) @@ -210,9 +211,9 @@ func (hs *HttpServer) registerRoutes() { // auth api keys apiRoute.Group("/auth/keys", func(keysRoute 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 @@ -225,16 +226,16 @@ func (hs *HttpServer) registerRoutes() { 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("/:id", wrap(DeleteDataSourceByID)) datasourceRoute.Delete("/name/:name", wrap(DeleteDataSourceByName)) - datasourceRoute.Get("/:id", wrap(GetDataSourceById)) + 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/settings", wrap(GetPluginSettingByID)) apiRoute.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown)) apiRoute.Group("/plugins", func(pluginRoute RouteRegister) { @@ -246,10 +247,28 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) + // Folders + apiRoute.Group("/folders", func(folderRoute RouteRegister) { + folderRoute.Get("/", wrap(GetFolders)) + folderRoute.Get("/id/:id", wrap(GetFolderByID)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) { + folderUidRoute.Get("/", wrap(GetFolderByUID)) + folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) + folderUidRoute.Delete("/", wrap(DeleteFolder)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) { + folderPermissionRoute.Get("/", wrap(GetFolderPermissionList)) + folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions)) + }) + }) + }) + // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) - dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid)) + dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUID)) dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard)) @@ -266,9 +285,9 @@ func (hs *HttpServer) registerRoutes() { dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) - dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { - aclRoute.Get("/", wrap(GetDashboardAclList)) - aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) + dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) { + dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList)) + dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions)) }) }) }) @@ -295,7 +314,7 @@ func (hs *HttpServer) registerRoutes() { // 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/gensql", reqGrafanaAdmin, wrap(GenerateSQLTestData)) apiRoute.Get("/tsdb/testdata/random-walk", wrap(GetTestDataRandomWalk)) apiRoute.Group("/alerts", func(alertsRoute RouteRegister) { @@ -313,7 +332,7 @@ func (hs *HttpServer) registerRoutes() { 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.Get("/:notificationId", wrap(GetAlertNotificationByID)) alertNotifications.Delete("/:notificationId", wrap(DeleteAlertNotification)) }, reqEditorRole) @@ -322,7 +341,7 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) { annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation)) - annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById)) + 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)) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index b2097104aba..7fda738f1cd 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -4,15 +4,14 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) -func GetApiKeys(c *middleware.Context) Response { +func GetAPIKeys(c *m.ReqContext) Response { query := m.GetApiKeysQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to list api keys", err) + return Error(500, "Failed to list api keys", err) } result := make([]*m.ApiKeyDTO, len(query.Result)) @@ -24,25 +23,25 @@ func GetApiKeys(c *middleware.Context) Response { } } - return Json(200, result) + return JSON(200, result) } -func DeleteApiKey(c *middleware.Context) Response { +func DeleteAPIKey(c *m.ReqContext) Response { id := c.ParamsInt64(":id") cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { - return ApiError(500, "Failed to delete API key", err) + return Error(500, "Failed to delete API key", err) } - return ApiSuccess("API key deleted") + return Success("API key deleted") } -func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) Response { +func AddAPIKey(c *m.ReqContext, cmd m.AddApiKeyCommand) Response { if !cmd.Role.IsValid() { - return ApiError(400, "Invalid role specified", nil) + return Error(400, "Invalid role specified", nil) } cmd.OrgId = c.OrgId @@ -51,12 +50,12 @@ func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) Response { cmd.Key = newKeyInfo.HashedKey if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to add API key", err) + return Error(500, "Failed to add API key", err) } result := &dtos.NewApiKeyResult{ Name: cmd.Result.Name, Key: newKeyInfo.ClientSecret} - return Json(200, result) + return JSON(200, result) } diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 0440c880979..0b7dcd32ce3 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -55,11 +55,11 @@ func InitAppPluginRoutes(r *macaron.Macaron) { } } -func AppPluginRoute(route *plugins.AppPluginRoute, appId string) macaron.Handler { - return func(c *middleware.Context) { +func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler { + return func(c *m.ReqContext) { path := c.Params("*") - proxy := pluginproxy.NewApiPluginProxy(c, path, route, appId) + proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID) proxy.Transport = pluginProxyTransport proxy.ServeHTTP(c.Resp, c.Req.Request) } diff --git a/pkg/api/common.go b/pkg/api/common.go index bd1c8be477d..97f41ff7c72 100644 --- a/pkg/api/common.go +++ b/pkg/api/common.go @@ -4,22 +4,22 @@ import ( "encoding/json" "net/http" - "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" ) var ( NotFound = func() Response { - return ApiError(404, "Not found", nil) + return Error(404, "Not found", nil) } ServerError = func(err error) Response { - return ApiError(500, "Server error", err) + return Error(500, "Server error", err) } ) type Response interface { - WriteTo(ctx *middleware.Context) + WriteTo(ctx *m.ReqContext) } type NormalResponse struct { @@ -32,7 +32,7 @@ type NormalResponse struct { func wrap(action interface{}) macaron.Handler { - return func(c *middleware.Context) { + return func(c *m.ReqContext) { var res Response val, err := c.Invoke(action) if err == nil && val != nil && len(val) > 0 { @@ -45,7 +45,7 @@ func wrap(action interface{}) macaron.Handler { } } -func (r *NormalResponse) WriteTo(ctx *middleware.Context) { +func (r *NormalResponse) WriteTo(ctx *m.ReqContext) { if r.err != nil { ctx.Logger.Error(r.errMessage, "error", r.err) } @@ -67,22 +67,25 @@ func (r *NormalResponse) Header(key, value string) *NormalResponse { return r } -// functions to create responses +// Empty create an empty response func Empty(status int) *NormalResponse { return Respond(status, nil) } -func Json(status int, body interface{}) *NormalResponse { +// JSON create a JSON response +func JSON(status int, body interface{}) *NormalResponse { return Respond(status, body).Header("Content-Type", "application/json") } -func ApiSuccess(message string) *NormalResponse { +// Success create a successful response +func Success(message string) *NormalResponse { resp := make(map[string]interface{}) resp["message"] = message - return Json(200, resp) + return JSON(200, resp) } -func ApiError(status int, message string, err error) *NormalResponse { +// Error create a erroneous response +func Error(status int, message string, err error) *NormalResponse { data := make(map[string]interface{}) switch status { @@ -102,7 +105,7 @@ func ApiError(status int, message string, err error) *NormalResponse { } } - resp := Json(status, data) + resp := JSON(status, data) if err != nil { resp.errMessage = message @@ -112,6 +115,7 @@ func ApiError(status int, message string, err error) *NormalResponse { return resp } +// Respond create a response func Respond(status int, body interface{}) *NormalResponse { var b []byte var err error @@ -122,7 +126,7 @@ func Respond(status int, body interface{}) *NormalResponse { b = []byte(t) default: if b, err = json.Marshal(body); err != nil { - return ApiError(500, "body json marshal", err) + return Error(500, "body json marshal", err) } } return &NormalResponse{ diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 33fc1688603..a4a547d8bbf 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,22 +8,22 @@ import ( "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" + m "github.com/grafana/grafana/pkg/models" + "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" ) func loggedInUserScenario(desc string, url string, fn scenarioFunc) { - loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) + loggedInUserScenarioWithRole(desc, "GET", url, url, m.ROLE_EDITOR, fn) } -func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { +func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role m.RoleType, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID @@ -71,7 +71,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map type scenarioContext struct { m *macaron.Macaron - context *middleware.Context + context *m.ReqContext resp *httptest.ResponseRecorder handlerFunc handlerFunc defaultHandler macaron.Handler @@ -84,7 +84,7 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *middleware.Context) Response +type handlerFunc func(c *m.ReqContext) Response func setupScenarioContext(url string) *scenarioContext { sc := &scenarioContext{ @@ -99,7 +99,7 @@ func setupScenarioContext(url string) *scenarioContext { })) sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) + sc.m.Use(middleware.Sessioner(&session.Options{}, 0)) return sc } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 32a09bcd931..11a028cdd29 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path" - "strings" "github.com/grafana/grafana/pkg/services/dashboards" @@ -15,20 +14,20 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) { +func isDashboardStarredByUser(c *m.ReqContext, dashID int64) (bool, error) { if !c.IsSignedIn { return false, nil } - query := m.IsStarredByUserQuery{UserId: c.UserId, DashboardId: dashId} + query := m.IsStarredByUserQuery{UserId: c.UserId, DashboardId: dashID} if err := bus.Dispatch(&query); err != nil { return false, err } @@ -38,19 +37,19 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) func dashboardGuardianResponse(err error) Response { if err != nil { - return ApiError(500, "Error while checking dashboard permissions", err) + return Error(500, "Error while checking dashboard permissions", err) } - return ApiError(403, "Access denied to this dashboard", nil) + return Error(403, "Access denied to this dashboard", nil) } -func GetDashboard(c *middleware.Context) Response { +func GetDashboard(c *m.ReqContext) Response { dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid")) if rsp != nil { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canView, err := guardian.CanView(); err != nil || !canView { return dashboardGuardianResponse(err) } @@ -61,7 +60,7 @@ func GetDashboard(c *middleware.Context) Response { isStarred, err := isDashboardStarredByUser(c, dash.Id) if err != nil { - return ApiError(500, "Error while checking if dashboard was starred by user", err) + return Error(500, "Error while checking if dashboard was starred by user", err) } // Finding creator and last updater of the dashboard @@ -97,7 +96,7 @@ func GetDashboard(c *middleware.Context) Response { if dash.FolderId > 0 { query := m.GetDashboardQuery{Id: dash.FolderId, OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Dashboard folder could not be read", err) + return Error(500, "Dashboard folder could not be read", err) } meta.FolderTitle = query.Result.Title meta.FolderUrl = query.Result.GetUrl() @@ -112,44 +111,43 @@ func GetDashboard(c *middleware.Context) Response { } c.TimeRequest(metrics.M_Api_Dashboard_Get) - return Json(200, dto) + return JSON(200, dto) } -func getUserLogin(userId int64) string { - query := m.GetUserByIdQuery{Id: userId} +func getUserLogin(userID int64) string { + query := m.GetUserByIdQuery{Id: userID} err := bus.Dispatch(&query) if err != nil { return "Anonymous" - } else { - user := query.Result - return user.Login } + return query.Result.Login } -func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dashboard, Response) { +func getDashboardHelper(orgID int64, slug string, id int64, uid string) (*m.Dashboard, Response) { var query m.GetDashboardQuery if len(uid) > 0 { - query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgId} + query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgID} } else { - query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} + query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgID} } if err := bus.Dispatch(&query); err != nil { - return nil, ApiError(404, "Dashboard not found", err) + return nil, Error(404, "Dashboard not found", err) } + return query.Result, nil } -func DeleteDashboard(c *middleware.Context) Response { +func DeleteDashboard(c *m.ReqContext) Response { query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to retrieve dashboards by slug", err) + return Error(500, "Failed to retrieve dashboards by slug", err) } if len(query.Result) > 1 { - return Json(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()}) + return JSON(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()}) } dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "") @@ -157,78 +155,57 @@ func DeleteDashboard(c *middleware.Context) Response { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete dashboard", err) + return Error(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return JSON(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } -func DeleteDashboardByUid(c *middleware.Context) Response { +func DeleteDashboardByUID(c *m.ReqContext) Response { dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid")) if rsp != nil { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete dashboard", err) + return Error(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return JSON(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } -func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { +func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { cmd.OrgId = c.OrgId cmd.UserId = c.UserId dash := cmd.GetDashboardModel() - dashId := dash.Id - - // if new dashboard, use parent folder permissions instead - if dashId == 0 { - dashId = cmd.FolderId - } - - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - - if dash.IsFolder && dash.FolderId > 0 { - return ApiError(400, m.ErrDashboardFolderCannotHaveParent.Error(), nil) - } - - // Check if Title is empty - if dash.Title == "" { - return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil) - } - - if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(m.RootFolderName) { - return ApiError(400, "A folder already exists with that name", nil) - } - - if dash.Id == 0 { - limitReached, err := middleware.QuotaReached(c, "dashboard") + if dash.Id == 0 && dash.Uid == "" { + limitReached, err := quota.QuotaReached(c, "dashboard") if err != nil { - return ApiError(500, "failed to get quota", err) + return Error(500, "failed to get quota", err) } if limitReached { - return ApiError(403, "Quota reached", nil) + return Error(403, "Quota reached", nil) } } @@ -236,32 +213,39 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { Dashboard: dash, Message: cmd.Message, OrgId: c.OrgId, - UserId: c.UserId, + User: c.SignedInUser, Overwrite: cmd.Overwrite, } - dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem) + dashboard, err := dashboards.NewService().SaveDashboard(dashItem) if err == m.ErrDashboardTitleEmpty || err == m.ErrDashboardWithSameNameAsFolder || err == m.ErrDashboardFolderWithSameNameAsDashboard || - err == m.ErrDashboardTypeMismatch { - return ApiError(400, err.Error(), nil) + err == m.ErrDashboardTypeMismatch || + err == m.ErrDashboardInvalidUid || + err == m.ErrDashboardUidToLong || + err == m.ErrDashboardWithSameUIDExists || + err == m.ErrFolderNotFound || + err == m.ErrDashboardFolderCannotHaveParent || + err == m.ErrDashboardFolderNameExists { + return Error(400, err.Error(), nil) + } + + if err == m.ErrDashboardUpdateAccessDenied { + return Error(403, err.Error(), err) } if err == m.ErrDashboardContainsInvalidAlertData { - return ApiError(500, "Invalid alert data. Cannot save dashboard", err) + return Error(500, "Invalid alert data. Cannot save dashboard", err) } if err != nil { - if err == m.ErrDashboardWithSameUIDExists { - return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) - } if err == m.ErrDashboardWithSameNameInFolderExists { - return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) + return JSON(412, util.DynMap{"status": "name-exists", "message": err.Error()}) } if err == m.ErrDashboardVersionMismatch { - return Json(412, util.DynMap{"status": "version-mismatch", "message": err.Error()}) + return JSON(412, util.DynMap{"status": "version-mismatch", "message": err.Error()}) } if pluginErr, ok := err.(m.UpdatePluginDashboardError); ok { message := "The dashboard belongs to plugin " + pluginErr.PluginId + "." @@ -269,22 +253,20 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { if pluginDef, exist := plugins.Plugins[pluginErr.PluginId]; exist { message = "The dashboard belongs to plugin " + pluginDef.Name + "." } - return Json(412, util.DynMap{"status": "plugin-dashboard", "message": message}) + return JSON(412, util.DynMap{"status": "plugin-dashboard", "message": message}) } if err == m.ErrDashboardNotFound { - return Json(404, util.DynMap{"status": "not-found", "message": err.Error()}) + return JSON(404, util.DynMap{"status": "not-found", "message": err.Error()}) } - return ApiError(500, "Failed to save dashboard", err) + return Error(500, "Failed to save dashboard", err) } if err == m.ErrDashboardFailedToUpdateAlertData { - return ApiError(500, "Invalid alert data. Cannot save dashboard", err) + return Error(500, "Invalid alert data. Cannot save dashboard", err) } - dashboard.IsFolder = dash.IsFolder - c.TimeRequest(metrics.M_Api_Dashboard_Save) - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "status": "success", "slug": dashboard.Slug, "version": dashboard.Version, @@ -294,10 +276,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { }) } -func GetHomeDashboard(c *middleware.Context) Response { +func GetHomeDashboard(c *m.ReqContext) Response { prefsQuery := m.GetPreferencesWithDefaultsQuery{OrgId: c.OrgId, UserId: c.UserId} if err := bus.Dispatch(&prefsQuery); err != nil { - return ApiError(500, "Failed to get preferences", err) + return Error(500, "Failed to get preferences", err) } if prefsQuery.Result.HomeDashboardId != 0 { @@ -306,16 +288,15 @@ func GetHomeDashboard(c *middleware.Context) Response { if err == nil { url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug) dashRedirect := dtos.DashboardRedirect{RedirectUri: url} - return Json(200, &dashRedirect) - } else { - log.Warn("Failed to get slug from database, %s", err.Error()) + return JSON(200, &dashRedirect) } + log.Warn("Failed to get slug from database, %s", err.Error()) } filePath := path.Join(setting.StaticRootPath, "dashboards/home.json") file, err := os.Open(filePath) if err != nil { - return ApiError(500, "Failed to load home dashboard", err) + return Error(500, "Failed to load home dashboard", err) } dash := dtos.DashboardFullWithMeta{} @@ -325,14 +306,14 @@ func GetHomeDashboard(c *middleware.Context) Response { jsonParser := json.NewDecoder(file) if err := jsonParser.Decode(&dash.Dashboard); err != nil { - return ApiError(500, "Failed to load home dashboard", err) + return Error(500, "Failed to load home dashboard", err) } if c.HasUserRole(m.ROLE_ADMIN) && !c.HasHelpFlag(m.HelpFlagGettingStartedPanelDismissed) { addGettingStartedPanelToHomeDashboard(dash.Dashboard) } - return Json(200, &dash) + return JSON(200, &dash) } func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { @@ -354,23 +335,23 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { } // GetDashboardVersions returns all dashboard versions as JSON -func GetDashboardVersions(c *middleware.Context) Response { - dashId := c.ParamsInt64(":dashboardId") +func GetDashboardVersions(c *m.ReqContext) Response { + dashID := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + guardian := guardian.New(dashID, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } query := m.GetDashboardVersionsQuery{ OrgId: c.OrgId, - DashboardId: dashId, + DashboardId: dashID, Limit: c.QueryInt("limit"), Start: c.QueryInt("start"), } if err := bus.Dispatch(&query); err != nil { - return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashId), err) + return Error(404, fmt.Sprintf("No versions found for dashboardId %d", dashID), err) } for _, version := range query.Result { @@ -389,26 +370,26 @@ func GetDashboardVersions(c *middleware.Context) Response { } } - return Json(200, query.Result) + return JSON(200, query.Result) } // GetDashboardVersion returns the dashboard version with the given ID. -func GetDashboardVersion(c *middleware.Context) Response { - dashId := c.ParamsInt64(":dashboardId") +func GetDashboardVersion(c *m.ReqContext) Response { + dashID := c.ParamsInt64(":dashboardId") - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) + guardian := guardian.New(dashID, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } query := m.GetDashboardVersionQuery{ OrgId: c.OrgId, - DashboardId: dashId, + DashboardId: dashID, Version: c.ParamsInt(":id"), } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashId), err) + return Error(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashID), err) } creator := "Anonymous" @@ -421,11 +402,23 @@ func GetDashboardVersion(c *middleware.Context) Response { CreatedBy: creator, } - return Json(200, dashVersionMeta) + return JSON(200, dashVersionMeta) } // POST /api/dashboards/calculate-diff performs diffs on two dashboards -func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response { +func CalculateDashboardDiff(c *m.ReqContext, apiOptions dtos.CalculateDiffOptions) Response { + + guardianBase := guardian.New(apiOptions.Base.DashboardId, c.OrgId, c.SignedInUser) + if canSave, err := guardianBase.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + if apiOptions.Base.DashboardId != apiOptions.New.DashboardId { + guardianNew := guardian.New(apiOptions.New.DashboardId, c.OrgId, c.SignedInUser) + if canSave, err := guardianNew.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + } options := dashdiffs.Options{ OrgId: c.OrgId, @@ -445,33 +438,33 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff result, err := dashdiffs.CalculateDiff(&options) if err != nil { if err == m.ErrDashboardVersionNotFound { - return ApiError(404, "Dashboard version not found", err) + return Error(404, "Dashboard version not found", err) } - return ApiError(500, "Unable to compute diff", err) + return Error(500, "Unable to compute diff", err) } if options.DiffType == dashdiffs.DiffDelta { return Respond(200, result.Delta).Header("Content-Type", "application/json") - } else { - return Respond(200, result.Delta).Header("Content-Type", "text/html") } + + return Respond(200, result.Delta).Header("Content-Type", "text/html") } // RestoreDashboardVersion restores a dashboard to the given version. -func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response { +func RestoreDashboardVersion(c *m.ReqContext, apiCmd dtos.RestoreDashboardVersionCommand) Response { dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "") if rsp != nil { return rsp } - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } versionQuery := m.GetDashboardVersionQuery{DashboardId: dash.Id, Version: apiCmd.Version, OrgId: c.OrgId} if err := bus.Dispatch(&versionQuery); err != nil { - return ApiError(404, "Dashboard version not found", nil) + return Error(404, "Dashboard version not found", nil) } version := versionQuery.Result @@ -488,7 +481,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard return PostDashboard(c, saveCmd) } -func GetDashboardTags(c *middleware.Context) { +func GetDashboardTags(c *m.ReqContext) { query := m.GetDashboardTagsQuery{OrgId: c.OrgId} err := bus.Dispatch(&query) if err != nil { diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go deleted file mode 100644 index d15a575a05e..00000000000 --- a/pkg/api/dashboard_acl.go +++ /dev/null @@ -1,88 +0,0 @@ -package api - -import ( - "time" - - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/guardian" -) - -func GetDashboardAclList(c *middleware.Context) Response { - dashId := c.ParamsInt64(":dashboardId") - - _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") - if rsp != nil { - return rsp - } - - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) - - if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { - return dashboardGuardianResponse(err) - } - - acl, err := guardian.GetAcl() - if err != nil { - return ApiError(500, "Failed to get dashboard acl", err) - } - - for _, perm := range acl { - perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail) - perm.TeamAvatarUrl = dtos.GetGravatarUrl(perm.TeamEmail) - if perm.Slug != "" { - perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) - } - } - - return Json(200, acl) -} - -func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { - dashId := c.ParamsInt64(":dashboardId") - - _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") - if rsp != nil { - return rsp - } - - guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) - if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { - return dashboardGuardianResponse(err) - } - - cmd := m.UpdateDashboardAclCommand{} - cmd.DashboardId = dashId - - for _, item := range apiCmd.Items { - cmd.Items = append(cmd.Items, &m.DashboardAcl{ - OrgId: c.OrgId, - DashboardId: dashId, - UserId: item.UserId, - TeamId: item.TeamId, - Role: item.Role, - Permission: item.Permission, - Created: time.Now(), - Updated: time.Now(), - }) - } - - if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { - if err != nil { - return ApiError(500, "Error while checking dashboard permissions", err) - } - - return ApiError(403, "Cannot remove own admin permission for a folder", nil) - } - - if err := bus.Dispatch(&cmd); err != nil { - if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty { - return ApiError(409, err.Error(), err) - } - return ApiError(500, "Failed to create permission", err) - } - - return ApiSuccess("Dashboard acl updated") -} diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go deleted file mode 100644 index d6b7e305daf..00000000000 --- a/pkg/api/dashboard_acl_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package api - -import ( - "testing" - - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestDashboardAclApiEndpoint(t *testing.T) { - Convey("Given a dashboard acl", t, func() { - mockResult := []*m.DashboardAclInfoDTO{ - {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, - {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, - {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, - {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, - {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, - } - dtoRes := transformDashboardAclsToDTOs(mockResult) - - getDashboardQueryResult := m.NewDashboard("Dash") - var getDashboardNotFoundError error - - bus.AddHandler("test", func(query *m.GetDashboardQuery) error { - query.Result = getDashboardQueryResult - return getDashboardNotFoundError - }) - - bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { - query.Result = dtoRes - return nil - }) - - bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { - query.Result = mockResult - return nil - }) - - teamResp := []*m.Team{} - bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = teamResp - return nil - }) - - // This tests four scenarios: - // 1. user is an org admin - // 2. user is an org editor AND has been granted admin permission for the dashboard - // 3. user is an org viewer AND has been granted edit permission for the dashboard - // 4. user is an org editor AND has no permissions for the dashboard - - Convey("When user is org admin", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { - Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - - respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) - So(err, ShouldBeNil) - So(len(respJSON.MustArray()), ShouldEqual, 5) - So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) - So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) - }) - }) - - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { - getDashboardNotFoundError = m.ErrDashboardNotFound - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - Convey("Should not be able to access ACL", func() { - So(sc.resp.Code, ShouldEqual, 404) - }) - }) - - Convey("Should not be able to update permissions for non-existing dashboard", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: 1000, Permission: m.PERMISSION_ADMIN}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { - getDashboardNotFoundError = m.ErrDashboardNotFound - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 404) - }) - }) - }) - - Convey("When user is org editor and has admin permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - Convey("Should not be able to downgrade their own Admin permission", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: TestUserID, Permission: m.PERMISSION_EDIT}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - - Convey("Should be able to update permissions", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: TestUserID, Permission: m.PERMISSION_ADMIN}, - {UserId: 2, Permission: m.PERMISSION_EDIT}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - }) - - Convey("When user is org viewer and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - - // Getting the permissions is an Admin permission - Convey("Should not be able to get list of permissions from ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - }) - - Convey("When user is org editor and not in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - - Convey("Should not be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - }) - }) -} - -func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO { - dtos := make([]*m.DashboardAclInfoDTO, 0) - - for _, acl := range acls { - dto := &m.DashboardAclInfoDTO{ - OrgId: acl.OrgId, - DashboardId: acl.DashboardId, - Permission: acl.Permission, - UserId: acl.UserId, - TeamId: acl.TeamId, - } - dtos = append(dtos, dto) - } - - return dtos -} - -func CallPostAcl(sc *scenarioContext) { - bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { - return nil - }) - - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() -} - -func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { - Convey(desc+" "+url, func() { - defer bus.ClearBusHandlers() - - sc := setupScenarioContext(url) - - sc.defaultHandler = wrap(func(c *middleware.Context) Response { - sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role - - return UpdateDashboardAcl(c, cmd) - }) - - sc.m.Post(routePattern, sc.defaultHandler) - - fn(sc) - }) -} diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go new file mode 100644 index 00000000000..2f85d6f55e8 --- /dev/null +++ b/pkg/api/dashboard_permission.go @@ -0,0 +1,92 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func GetDashboardPermissionList(c *m.ReqContext) Response { + dashID := c.ParamsInt64(":dashboardId") + + _, rsp := getDashboardHelper(c.OrgId, "", dashID, "") + if rsp != nil { + return rsp + } + + g := guardian.New(dashID, c.OrgId, c.SignedInUser) + + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { + return dashboardGuardianResponse(err) + } + + acl, err := g.GetAcl() + if err != nil { + return Error(500, "Failed to get dashboard permissions", err) + } + + for _, perm := range acl { + perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail) + perm.TeamAvatarUrl = dtos.GetGravatarUrl(perm.TeamEmail) + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + + return JSON(200, acl) +} + +func UpdateDashboardPermissions(c *m.ReqContext, apiCmd dtos.UpdateDashboardAclCommand) Response { + dashID := c.ParamsInt64(":dashboardId") + + _, rsp := getDashboardHelper(c.OrgId, "", dashID, "") + if rsp != nil { + return rsp + } + + g := guardian.New(dashID, c.OrgId, c.SignedInUser) + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { + return dashboardGuardianResponse(err) + } + + cmd := m.UpdateDashboardAclCommand{} + cmd.DashboardId = dashID + + for _, item := range apiCmd.Items { + cmd.Items = append(cmd.Items, &m.DashboardAcl{ + OrgId: c.OrgId, + DashboardId: dashID, + UserId: item.UserId, + TeamId: item.TeamId, + Role: item.Role, + Permission: item.Permission, + Created: time.Now(), + Updated: time.Now(), + }) + } + + if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + if err == guardian.ErrGuardianPermissionExists || + err == guardian.ErrGuardianOverride { + return Error(400, err.Error(), err) + } + + return Error(500, "Error while checking dashboard permissions", err) + } + + return Error(403, "Cannot remove own admin permission for a folder", nil) + } + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty { + return Error(409, err.Error(), err) + } + return Error(500, "Failed to create permission", err) + } + + return Success("Dashboard permissions updated") +} diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go new file mode 100644 index 00000000000..24f0bdca365 --- /dev/null +++ b/pkg/api/dashboard_permission_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardPermissionApiEndpoint(t *testing.T) { + Convey("Dashboard permissions test", t, func() { + Convey("Given dashboard not exists", func() { + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + return m.ErrDashboardNotFound + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("Given user has no admin permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has admin permissions and permissions to update", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: true, + GetAclValue: []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + }, + }) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 5) + So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) + So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("When trying to update permissions with duplicate permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, + }) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("When trying to override inherited permissions with lower precedence", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, + ) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + }) +} + +func callGetDashboardPermissions(sc *scenarioContext) { + sc.handlerFunc = GetDashboardPermissionList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callUpdateDashboardPermissions(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func updateDashboardPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.OrgId = TestOrgID + sc.context.UserId = TestUserID + + return UpdateDashboardPermissions(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index a834bd4717d..f474d357df5 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -6,13 +6,13 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -func GetSharingOptions(c *middleware.Context) { +func GetSharingOptions(c *m.ReqContext) { c.JSON(200, util.DynMap{ "externalSnapshotURL": setting.ExternalSnapshotUrl, "externalSnapshotName": setting.ExternalSnapshotName, @@ -20,7 +20,7 @@ func GetSharingOptions(c *middleware.Context) { }) } -func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapshotCommand) { +func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotCommand) { if cmd.Name == "" { cmd.Name = "Unnamed snapshot" } @@ -56,7 +56,8 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho }) } -func GetDashboardSnapshot(c *middleware.Context) { +// GET /api/snapshots/:key +func GetDashboardSnapshot(c *m.ReqContext) { key := c.Params(":key") query := &m.GetDashboardSnapshotQuery{Key: key} @@ -90,19 +91,44 @@ func GetDashboardSnapshot(c *middleware.Context) { c.JSON(200, dto) } -func DeleteDashboardSnapshot(c *middleware.Context) { +// GET /api/snapshots-delete/:key +func DeleteDashboardSnapshot(c *m.ReqContext) Response { key := c.Params(":key") + + query := &m.GetDashboardSnapshotQuery{DeleteKey: key} + + err := bus.Dispatch(query) + if err != nil { + return Error(500, "Failed to get dashboard snapshot", err) + } + + if query.Result == nil { + return Error(404, "Failed to get dashboard snapshot", nil) + } + dashboard := query.Result.Dashboard + dashboardID := dashboard.Get("id").MustInt64() + + guardian := guardian.New(dashboardID, c.OrgId, c.SignedInUser) + canEdit, err := guardian.CanEdit() + if err != nil { + return Error(500, "Error while checking permissions for snapshot", err) + } + + if !canEdit && query.Result.UserId != c.SignedInUser.UserId { + return Error(403, "Access denied to this snapshot", nil) + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key} if err := bus.Dispatch(cmd); err != nil { - c.JsonApiErr(500, "Failed to delete dashboard snapshot", err) - return + return Error(500, "Failed to delete dashboard snapshot", err) } - c.JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}) + return JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}) } -func SearchDashboardSnapshots(c *middleware.Context) Response { +// GET /api/dashboard/snapshots +func SearchDashboardSnapshots(c *m.ReqContext) Response { query := c.Query("query") limit := c.QueryInt("limit") @@ -111,14 +137,15 @@ func SearchDashboardSnapshots(c *middleware.Context) Response { } searchQuery := m.GetDashboardSnapshotsQuery{ - Name: query, - Limit: limit, - OrgId: c.OrgId, + Name: query, + Limit: limit, + OrgId: c.OrgId, + SignedInUser: c.SignedInUser, } err := bus.Dispatch(&searchQuery) if err != nil { - return ApiError(500, "Search failed", err) + return Error(500, "Search failed", err) } dtos := make([]*m.DashboardSnapshotDTO, len(searchQuery.Result)) @@ -138,5 +165,5 @@ func SearchDashboardSnapshots(c *middleware.Context) Response { } } - return Json(200, dtos) + return JSON(200, dtos) } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go new file mode 100644 index 00000000000..87c2b9e99d4 --- /dev/null +++ b/pkg/api/dashboard_snapshot_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardSnapshotApiEndpoint(t *testing.T) { + Convey("Given a single snapshot", t, func() { + jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`)) + + mockSnapshotResult := &m.DashboardSnapshot{ + Id: 1, + Dashboard: jsonModel, + Expires: time.Now().Add(time.Duration(1000) * time.Second), + UserId: 999999, + } + + bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error { + query.Result = mockSnapshotResult + return nil + }) + + bus.AddHandler("test", func(cmd *m.DeleteDashboardSnapshotCommand) error { + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + aclMockResp := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + teamResp := []*m.Team{} + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = teamResp + return nil + }) + + Convey("When user has editor role and is not in the ACL", func() { + Convey("Should not be able to delete snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and dashboard has default ACL", func() { + aclMockResp = []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + Convey("Should be able to delete a snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + }) + }) + }) + + Convey("When user is editor and is the creator of the snapshot", func() { + aclMockResp = []*m.DashboardAclInfoDTO{} + mockSnapshotResult.UserId = TestUserID + + Convey("Should be able to delete a snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + }) + }) + }) + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 4a45c561d57..0d87023ce40 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -2,45 +2,24 @@ package api import ( "encoding/json" + "fmt" "testing" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) -type fakeDashboardRepo struct { - inserted []*dashboards.SaveDashboardDTO - provisioned []*m.DashboardProvisioning - getDashboard []*m.Dashboard -} - -func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardDTO) (*m.Dashboard, error) { - repo.inserted = append(repo.inserted, json) - return json.Dashboard, nil -} - -func (repo *fakeDashboardRepo) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *m.DashboardProvisioning) (*m.Dashboard, error) { - repo.inserted = append(repo.inserted, dto) - return dto.Dashboard, nil -} - -func (repo *fakeDashboardRepo) GetProvisionedDashboardData(name string) ([]*m.DashboardProvisioning, error) { - return repo.provisioned, nil -} - -var fakeRepo *fakeDashboardRepo - -// This tests two main scenarios. If a user has access to execute an action on a dashboard: -// 1. and the dashboard is in a folder which does not have an acl -// 2. and the dashboard is in a folder which does have an acl +// This tests three main scenarios. +// If a user has access to execute an action on a dashboard: +// 1. and the dashboard is in a folder which does not have an acl +// 2. and the dashboard is in a folder which does have an acl +// 3. Post dashboard response tests func TestDashboardApiEndpoint(t *testing.T) { Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { @@ -81,14 +60,6 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - cmd := m.SaveDashboardCommand{ - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - "id": fakeDash.Id, - }), - } - // This tests two scenarios: // 1. user is an org viewer // 2. user is an org editor @@ -134,7 +105,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by uid", func() { @@ -151,11 +122,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Editor", func() { @@ -199,7 +165,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by uid", func() { @@ -216,32 +182,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) - - Convey("When saving a dashboard folder in another folder", func() { - bus.AddHandler("test", func(query *m.GetDashboardQuery) error { - query.Result = fakeDash - query.Result.IsFolder = true - return nil - }) - invalidCmd := m.SaveDashboardCommand{ - FolderId: fakeDash.FolderId, - IsFolder: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - }), - } - Convey("Should return an error", func() { - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, invalidCmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 400) - }) - }) - }) }) }) @@ -284,15 +224,6 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - cmd := m.SaveDashboardCommand{ - FolderId: fakeDash.FolderId, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": fakeDash.Id, - "folderId": fakeDash.FolderId, - "title": fakeDash.Title, - }), - } - // This tests six scenarios: // 1. user is an org viewer AND has no permissions for this dashboard // 2. user is an org editor AND has no permissions for this dashboard @@ -340,7 +271,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by uid", func() { @@ -357,11 +288,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Editor and has no permissions for this dashboard", func() { @@ -403,7 +329,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by uid", func() { @@ -420,11 +346,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) Convey("When user is an Org Viewer but has an edit permission", func() { @@ -477,7 +398,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by uid", func() { @@ -494,10 +415,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) }) Convey("When user is an Org Viewer and viewers can edit", func() { @@ -551,7 +468,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by uid", func() { @@ -610,7 +527,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by uid", func() { @@ -627,10 +544,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 200) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboardShouldReturnSuccess(sc) - }) }) Convey("When user is an Org Editor but has a view permission", func() { @@ -681,7 +594,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { - CallDeleteDashboardByUid(sc) + CallDeleteDashboardByUID(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by uid", func() { @@ -698,11 +611,6 @@ func TestDashboardApiEndpoint(t *testing.T) { CallGetDashboardVersions(sc) So(sc.resp.Code, ShouldEqual, 403) }) - - postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) }) }) @@ -730,17 +638,161 @@ func TestDashboardApiEndpoint(t *testing.T) { Convey("Should result in 412 Precondition failed", func() { So(sc.resp.Code, ShouldEqual, 412) - result := sc.ToJson() + result := sc.ToJSON() So(result.Get("status").MustString(), ShouldEqual, "multiple-slugs-exists") So(result.Get("message").MustString(), ShouldEqual, m.ErrDashboardsWithSameSlugExists.Error()) }) }) }) + + Convey("Post dashboard response tests", t, func() { + + // This tests that a valid request returns correct response + + Convey("Given a correct request for creating a dashboard", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + UserId: 5, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + Overwrite: true, + FolderId: 3, + IsFolder: false, + Message: "msg", + } + + mock := &dashboards.FakeDashboardService{ + SaveDashboardResult: &m.Dashboard{ + Id: 2, + Uid: "uid", + Title: "Dash", + Slug: "dash", + Version: 2, + }, + } + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) { + CallPostDashboardShouldReturnSuccess(sc) + + Convey("It should call dashboard service with correct data", func() { + dto := mock.SavedDashboards[0] + So(dto.OrgId, ShouldEqual, cmd.OrgId) + So(dto.User.UserId, ShouldEqual, cmd.UserId) + So(dto.Dashboard.FolderId, ShouldEqual, 3) + So(dto.Dashboard.Title, ShouldEqual, "Dash") + So(dto.Overwrite, ShouldBeTrue) + So(dto.Message, ShouldEqual, "msg") + }) + + Convey("It should return correct response data", func() { + result := sc.ToJSON() + So(result.Get("status").MustString(), ShouldEqual, "success") + So(result.Get("id").MustInt64(), ShouldEqual, 2) + So(result.Get("uid").MustString(), ShouldEqual, "uid") + So(result.Get("slug").MustString(), ShouldEqual, "dash") + So(result.Get("url").MustString(), ShouldEqual, "/d/uid/dash") + }) + }) + }) + + // This tests that invalid requests returns expected error responses + + Convey("Given incorrect requests for creating a dashboard", func() { + testCases := []struct { + SaveError error + ExpectedStatusCode int + }{ + {SaveError: m.ErrDashboardNotFound, ExpectedStatusCode: 404}, + {SaveError: m.ErrFolderNotFound, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameUIDExists, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameNameInFolderExists, ExpectedStatusCode: 412}, + {SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412}, + {SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + {SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardWithSameNameAsFolder, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardFolderNameExists, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: 403}, + {SaveError: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {SaveError: m.UpdatePluginDashboardError{PluginId: "plug"}, ExpectedStatusCode: 412}, + } + + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "", + }), + } + + for _, tc := range testCases { + mock := &dashboards.FakeDashboardService{ + SaveDashboardError: tc.SaveError, + } + + postDashboardScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.SaveError.Error()), "/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, tc.ExpectedStatusCode) + }) + } + }) + }) + + Convey("Given two dashboards being compared", t, func() { + mockResult := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { + query.Result = &m.DashboardVersion{ + Data: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash" + string(query.DashboardId), + }), + } + return nil + }) + + cmd := dtos.CalculateDiffOptions{ + Base: dtos.CalculateDiffTarget{ + DashboardId: 1, + Version: 1, + }, + New: dtos.CalculateDiffTarget{ + DashboardId: 2, + Version: 2, + }, + DiffType: "basic", + } + + Convey("when user does not have permission", func() { + role := m.ROLE_VIEWER + + postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("when user does have permission", func() { + role := m.ROLE_ADMIN + + postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { - sc.handlerFunc = GetDashboard - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + CallGetDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) @@ -751,6 +803,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta return dash } +func CallGetDashboard(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + func CallGetDashboardVersion(sc *scenarioContext) { bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { query.Result = &m.DashboardVersion{} @@ -780,29 +837,16 @@ func CallDeleteDashboard(sc *scenarioContext) { sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } -func CallDeleteDashboardByUid(sc *scenarioContext) { +func CallDeleteDashboardByUID(sc *scenarioContext) { bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error { return nil }) - sc.handlerFunc = DeleteDashboardByUid + sc.handlerFunc = DeleteDashboardByUID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } func CallPostDashboard(sc *scenarioContext) { - bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error { - return nil - }) - - bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { - cmd.Result = &m.Dashboard{Id: 2, Slug: "Dash", Version: 2} - return nil - }) - - bus.AddHandler("test", func(cmd *alerting.UpdateDashboardAlertsCommand) error { - return nil - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } @@ -810,30 +854,48 @@ func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) { CallPostDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) - result := sc.ToJson() - So(result.Get("status").MustString(), ShouldEqual, "success") - So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0) - So(result.Get("uid").MustString(), ShouldNotBeNil) - So(result.Get("slug").MustString(), ShouldNotBeNil) - So(result.Get("url").MustString(), ShouldNotBeNil) } -func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) { +func postDashboardScenario(desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd m.SaveDashboardCommand, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role + sc.context.SignedInUser = &m.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId} return PostDashboard(c, cmd) }) - fakeRepo = &fakeDashboardRepo{} - dashboards.SetRepository(fakeRepo) + origNewDashboardService := dashboards.NewService + dashboards.MockDashboardService(mock) + + sc.m.Post(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewService = origNewDashboardService + }() + + fn(sc) + }) +} + +func postDiffScenario(desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role m.RoleType, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{ + OrgId: TestOrgID, + UserId: TestUserID, + } + sc.context.OrgRole = role + + return CalculateDashboardDiff(c, cmd) + }) sc.m.Post(routePattern, sc.defaultHandler) @@ -841,7 +903,7 @@ func postDashboardScenario(desc string, url string, routePattern string, role m. }) } -func (sc *scenarioContext) ToJson() *simplejson.Json { +func (sc *scenarioContext) ToJSON() *simplejson.Json { var result *simplejson.Json err := json.NewDecoder(sc.resp.Body).Decode(&result) So(err, ShouldBeNil) diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 8a712f99804..33839ca985d 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -7,26 +7,25 @@ import ( "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ) const HeaderNameNoBackendCache = "X-Grafana-NoCache" -func (hs *HttpServer) getDatasourceById(id int64, orgId int64, nocache bool) (*m.DataSource, error) { +func (hs *HTTPServer) getDatasourceByID(id int64, orgID int64, nocache bool) (*m.DataSource, error) { cacheKey := fmt.Sprintf("ds-%d", id) if !nocache { if cached, found := hs.cache.Get(cacheKey); found { ds := cached.(*m.DataSource) - if ds.OrgId == orgId { + if ds.OrgId == orgID { return ds, nil } } } - query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgId} + query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgID} if err := bus.Dispatch(&query); err != nil { return nil, err } @@ -35,12 +34,12 @@ func (hs *HttpServer) getDatasourceById(id int64, orgId int64, nocache bool) (*m return query.Result, nil } -func (hs *HttpServer) ProxyDataSourceRequest(c *middleware.Context) { +func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" - ds, err := hs.getDatasourceById(c.ParamsInt64(":id"), c.OrgId, nocache) + ds, err := hs.getDatasourceByID(c.ParamsInt64(":id"), c.OrgId, nocache) if err != nil { c.JsonApiErr(500, "Unable to load datasource meta data", err) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index b5c5f9cb834..99677a93ee6 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -5,17 +5,16 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/util" ) -func GetDataSources(c *middleware.Context) Response { +func GetDataSources(c *m.ReqContext) Response { query := m.GetDataSourcesQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to query datasources", err) + return Error(500, "Failed to query datasources", err) } result := make(dtos.DataSourceList, 0) @@ -47,10 +46,10 @@ func GetDataSources(c *middleware.Context) Response { sort.Sort(result) - return Json(200, &result) + return JSON(200, &result) } -func GetDataSourceById(c *middleware.Context) Response { +func GetDataSourceByID(c *m.ReqContext) Response { query := m.GetDataSourceByIdQuery{ Id: c.ParamsInt64(":id"), OrgId: c.OrgId, @@ -58,81 +57,81 @@ func GetDataSourceById(c *middleware.Context) Response { if err := bus.Dispatch(&query); err != nil { if err == m.ErrDataSourceNotFound { - return ApiError(404, "Data source not found", nil) + return Error(404, "Data source not found", nil) } - return ApiError(500, "Failed to query datasources", err) + return Error(500, "Failed to query datasources", err) } ds := query.Result dtos := convertModelToDtos(ds) - return Json(200, &dtos) + return JSON(200, &dtos) } -func DeleteDataSourceById(c *middleware.Context) Response { +func DeleteDataSourceByID(c *m.ReqContext) Response { id := c.ParamsInt64(":id") if id <= 0 { - return ApiError(400, "Missing valid datasource id", nil) + return Error(400, "Missing valid datasource id", nil) } - ds, err := getRawDataSourceById(id, c.OrgId) + ds, err := getRawDataSourceByID(id, c.OrgId) if err != nil { - return ApiError(400, "Failed to delete datasource", nil) + return Error(400, "Failed to delete datasource", nil) } if ds.ReadOnly { - return ApiError(403, "Cannot delete read-only data source", nil) + return Error(403, "Cannot delete read-only data source", nil) } cmd := &m.DeleteDataSourceByIdCommand{Id: id, OrgId: c.OrgId} err = bus.Dispatch(cmd) if err != nil { - return ApiError(500, "Failed to delete datasource", err) + return Error(500, "Failed to delete datasource", err) } - return ApiSuccess("Data source deleted") + return Success("Data source deleted") } -func DeleteDataSourceByName(c *middleware.Context) Response { +func DeleteDataSourceByName(c *m.ReqContext) Response { name := c.Params(":name") if name == "" { - return ApiError(400, "Missing valid datasource name", nil) + return Error(400, "Missing valid datasource name", nil) } getCmd := &m.GetDataSourceByNameQuery{Name: name, OrgId: c.OrgId} if err := bus.Dispatch(getCmd); err != nil { - return ApiError(500, "Failed to delete datasource", err) + return Error(500, "Failed to delete datasource", err) } if getCmd.Result.ReadOnly { - return ApiError(403, "Cannot delete read-only data source", nil) + return Error(403, "Cannot delete read-only data source", nil) } cmd := &m.DeleteDataSourceByNameCommand{Name: name, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { - return ApiError(500, "Failed to delete datasource", err) + return Error(500, "Failed to delete datasource", err) } - return ApiSuccess("Data source deleted") + return Success("Data source deleted") } -func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) Response { +func AddDataSource(c *m.ReqContext, cmd m.AddDataSourceCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrDataSourceNameExists { - return ApiError(409, err.Error(), err) + return Error(409, err.Error(), err) } - return ApiError(500, "Failed to add datasource", err) + return Error(500, "Failed to add datasource", err) } ds := convertModelToDtos(cmd.Result) - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "message": "Datasource added", "id": cmd.Result.Id, "name": cmd.Result.Name, @@ -140,25 +139,24 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) Response { }) } -func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) Response { +func UpdateDataSource(c *m.ReqContext, cmd m.UpdateDataSourceCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":id") - err := fillWithSecureJsonData(&cmd) + err := fillWithSecureJSONData(&cmd) if err != nil { - return ApiError(500, "Failed to update datasource", err) + return Error(500, "Failed to update datasource", err) } err = bus.Dispatch(&cmd) if err != nil { if err == m.ErrDataSourceUpdatingOldVersion { - return ApiError(500, "Failed to update datasource. Reload new version and try again", err) - } else { - return ApiError(500, "Failed to update datasource", err) + return Error(500, "Failed to update datasource. Reload new version and try again", err) } + return Error(500, "Failed to update datasource", err) } ds := convertModelToDtos(cmd.Result) - return Json(200, util.DynMap{ + return JSON(200, util.DynMap{ "message": "Datasource updated", "id": cmd.Id, "name": cmd.Name, @@ -166,12 +164,12 @@ func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) Resp }) } -func fillWithSecureJsonData(cmd *m.UpdateDataSourceCommand) error { +func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error { if len(cmd.SecureJsonData) == 0 { return nil } - ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId) + ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId) if err != nil { return err } @@ -180,8 +178,8 @@ func fillWithSecureJsonData(cmd *m.UpdateDataSourceCommand) error { return m.ErrDatasourceIsReadOnly } - secureJsonData := ds.SecureJsonData.Decrypt() - for k, v := range secureJsonData { + secureJSONData := ds.SecureJsonData.Decrypt() + for k, v := range secureJSONData { if _, ok := cmd.SecureJsonData[k]; !ok { cmd.SecureJsonData[k] = v @@ -191,10 +189,10 @@ func fillWithSecureJsonData(cmd *m.UpdateDataSourceCommand) error { return nil } -func getRawDataSourceById(id int64, orgId int64) (*m.DataSource, error) { +func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) { query := m.GetDataSourceByIdQuery{ Id: id, - OrgId: orgId, + OrgId: orgID, } if err := bus.Dispatch(&query); err != nil { @@ -205,30 +203,30 @@ func getRawDataSourceById(id int64, orgId int64) (*m.DataSource, error) { } // Get /api/datasources/name/:name -func GetDataSourceByName(c *middleware.Context) Response { +func GetDataSourceByName(c *m.ReqContext) Response { query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { if err == m.ErrDataSourceNotFound { - return ApiError(404, "Data source not found", nil) + return Error(404, "Data source not found", nil) } - return ApiError(500, "Failed to query datasources", err) + return Error(500, "Failed to query datasources", err) } dtos := convertModelToDtos(query.Result) dtos.ReadOnly = true - return Json(200, &dtos) + return JSON(200, &dtos) } // Get /api/datasources/id/:name -func GetDataSourceIdByName(c *middleware.Context) Response { +func GetDataSourceIDByName(c *m.ReqContext) Response { query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { if err == m.ErrDataSourceNotFound { - return ApiError(404, "Data source not found", nil) + return Error(404, "Data source not found", nil) } - return ApiError(500, "Failed to query datasources", err) + return Error(500, "Failed to query datasources", err) } ds := query.Result @@ -236,7 +234,7 @@ func GetDataSourceIdByName(c *middleware.Context) Response { Id: ds.Id, } - return Json(200, &dtos) + return JSON(200, &dtos) } func convertModelToDtos(ds *m.DataSource) dtos.DataSource { diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go new file mode 100644 index 00000000000..469656c6f8f --- /dev/null +++ b/pkg/api/dtos/folder.go @@ -0,0 +1,25 @@ +package dtos + +import "time" + +type Folder struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` + Url string `json:"url"` + HasAcl bool `json:"hasAcl"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CreatedBy string `json:"createdBy"` + Created time.Time `json:"created"` + UpdatedBy string `json:"updatedBy"` + Updated time.Time `json:"updated"` + Version int `json:"version"` +} + +type FolderSearchHit struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` +} diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index a702b06fad5..2348e217a41 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -50,6 +50,10 @@ type UserStars struct { } func GetGravatarUrl(text string) string { + if setting.DisableGravatar { + return "/public/img/user_profile.png" + } + if text == "" { return "" } diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index 97e20bb4011..ef1ea4040e5 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -2,12 +2,12 @@ package dtos type Prefs struct { Theme string `json:"theme"` - HomeDashboardId int64 `json:"homeDashboardId"` + HomeDashboardID int64 `json:"homeDashboardId"` Timezone string `json:"timezone"` } type UpdatePrefsCmd struct { Theme string `json:"theme"` - HomeDashboardId int64 `json:"homeDashboardId"` + HomeDashboardID int64 `json:"homeDashboardId"` Timezone string `json:"timezone"` } diff --git a/pkg/api/folder.go b/pkg/api/folder.go new file mode 100644 index 00000000000..f0cdff24d20 --- /dev/null +++ b/pkg/api/folder.go @@ -0,0 +1,146 @@ +package api + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/api/dtos" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +func GetFolders(c *m.ReqContext) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folders, err := s.GetFolders(c.QueryInt("limit")) + + if err != nil { + return toFolderError(err) + } + + result := make([]dtos.FolderSearchHit, 0) + + for _, f := range folders { + result = append(result, dtos.FolderSearchHit{ + Id: f.Id, + Uid: f.Uid, + Title: f.Title, + }) + } + + return JSON(200, result) +} + +func GetFolderByUID(c *m.ReqContext) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUID(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return JSON(200, toFolderDto(g, folder)) +} + +func GetFolderByID(c *m.ReqContext) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByID(c.ParamsInt64(":id")) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return JSON(200, toFolderDto(g, folder)) +} + +func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.CreateFolder(&cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return JSON(200, toFolderDto(g, cmd.Result)) +} + +func UpdateFolder(c *m.ReqContext, cmd m.UpdateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.UpdateFolder(c.Params(":uid"), &cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return JSON(200, toFolderDto(g, cmd.Result)) +} + +func DeleteFolder(c *m.ReqContext) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + f, err := s.DeleteFolder(c.Params(":uid")) + if err != nil { + return toFolderError(err) + } + + return JSON(200, util.DynMap{ + "title": f.Title, + "message": fmt.Sprintf("Folder %s deleted", f.Title), + }) +} + +func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder { + canEdit, _ := g.CanEdit() + canSave, _ := g.CanSave() + canAdmin, _ := g.CanAdmin() + + // Finding creator and last updater of the folder + updater, creator := "Anonymous", "Anonymous" + if folder.CreatedBy > 0 { + creator = getUserLogin(folder.CreatedBy) + } + if folder.UpdatedBy > 0 { + updater = getUserLogin(folder.UpdatedBy) + } + + return dtos.Folder{ + Id: folder.Id, + Uid: folder.Uid, + Title: folder.Title, + Url: folder.Url, + HasAcl: folder.HasAcl, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, + } +} + +func toFolderError(err error) Response { + if err == m.ErrFolderTitleEmpty || + err == m.ErrFolderSameNameExists || + err == m.ErrFolderWithSameUIDExists || + err == m.ErrDashboardTypeMismatch || + err == m.ErrDashboardInvalidUid || + err == m.ErrDashboardUidToLong { + return Error(400, err.Error(), nil) + } + + if err == m.ErrFolderAccessDenied { + return Error(403, "Access denied", err) + } + + if err == m.ErrFolderNotFound { + return JSON(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()}) + } + + if err == m.ErrFolderVersionMismatch { + return JSON(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()}) + } + + return Error(500, "Folder API error", err) +} diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go new file mode 100644 index 00000000000..0d0904c99ea --- /dev/null +++ b/pkg/api/folder_permission.go @@ -0,0 +1,107 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func GetFolderPermissionList(c *m.ReqContext) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUID(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + acl, err := g.GetAcl() + if err != nil { + return Error(500, "Failed to get folder permissions", err) + } + + for _, perm := range acl { + perm.FolderId = folder.Id + perm.DashboardId = 0 + + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + + return JSON(200, acl) +} + +func UpdateFolderPermissions(c *m.ReqContext, apiCmd dtos.UpdateDashboardAclCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUID(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + canAdmin, err := g.CanAdmin() + if err != nil { + return toFolderError(err) + } + + if !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + cmd := m.UpdateDashboardAclCommand{} + cmd.DashboardId = folder.Id + + for _, item := range apiCmd.Items { + cmd.Items = append(cmd.Items, &m.DashboardAcl{ + OrgId: c.OrgId, + DashboardId: folder.Id, + UserId: item.UserId, + TeamId: item.TeamId, + Role: item.Role, + Permission: item.Permission, + Created: time.Now(), + Updated: time.Now(), + }) + } + + if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + if err == guardian.ErrGuardianPermissionExists || + err == guardian.ErrGuardianOverride { + return Error(400, err.Error(), err) + } + + return Error(500, "Error while checking folder permissions", err) + } + + return Error(403, "Cannot remove own admin permission for a folder", nil) + } + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrDashboardAclInfoMissing { + err = m.ErrFolderAclInfoMissing + } + if err == m.ErrDashboardPermissionDashboardEmpty { + err = m.ErrFolderPermissionFolderEmpty + } + + if err == m.ErrFolderAclInfoMissing || err == m.ErrFolderPermissionFolderEmpty { + return Error(409, err.Error(), err) + } + + return Error(500, "Failed to create permission", err) + } + + return Success("Folder permissions updated") +} diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go new file mode 100644 index 00000000000..f7458af6dce --- /dev/null +++ b/pkg/api/folder_permission_test.go @@ -0,0 +1,241 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderPermissionApiEndpoint(t *testing.T) { + Convey("Folder permissions test", t, func() { + Convey("Given folder not exists", func() { + mock := &fakeFolderService{ + GetFolderByUIDError: m.ErrFolderNotFound, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + Reset(func() { + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("Given user has no admin permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) + + mock := &fakeFolderService{ + GetFolderByUIDResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("Given user has admin permissions and permissions to update", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: true, + GetAclValue: []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + }, + }) + + mock := &fakeFolderService{ + GetFolderByUIDResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 5) + So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) + So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("When trying to update permissions with duplicate permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, + }) + + mock := &fakeFolderService{ + GetFolderByUIDResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("When trying to override inherited permissions with lower presedence", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, + ) + + mock := &fakeFolderService{ + GetFolderByUIDResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + }) +} + +func callGetFolderPermissions(sc *scenarioContext) { + sc.handlerFunc = GetFolderPermissionList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callUpdateFolderPermissions(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func updateFolderPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.OrgId = TestOrgID + sc.context.UserId = TestUserID + + return UpdateFolderPermissions(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go new file mode 100644 index 00000000000..0d9b9495686 --- /dev/null +++ b/pkg/api/folder_test.go @@ -0,0 +1,251 @@ +package api + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFoldersApiEndpoint(t *testing.T) { + Convey("Create/update folder response tests", t, func() { + Convey("Given a correct request for creating a folder", func() { + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + mock := &fakeFolderService{ + CreateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder"}, + } + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder") + }) + }) + }) + + Convey("Given incorrect requests for creating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + CreateFolderError: tc.Error, + } + + createFolderScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + + Convey("Given a correct request for updating a folder", func() { + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + mock := &fakeFolderService{ + UpdateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder upd"}, + } + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder upd") + }) + }) + }) + + Convey("Given incorrect requests for updating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + UpdateFolderError: tc.Error, + } + + updateFolderScenario(fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + }) +} + +func callGetFolderByUID(sc *scenarioContext) { + sc.handlerFunc = GetFolderByUID + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callDeleteFolder(sc *scenarioContext) { + sc.handlerFunc = DeleteFolder + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + +func callCreateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func createFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.CreateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return CreateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Post(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +func callUpdateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() +} + +func updateFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.UpdateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return UpdateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Put(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +type fakeFolderService struct { + GetFoldersResult []*m.Folder + GetFoldersError error + GetFolderByUIDResult *m.Folder + GetFolderByUIDError error + GetFolderByIDResult *m.Folder + GetFolderByIDError error + CreateFolderResult *m.Folder + CreateFolderError error + UpdateFolderResult *m.Folder + UpdateFolderError error + DeleteFolderResult *m.Folder + DeleteFolderError error + DeletedFolderUids []string +} + +func (s *fakeFolderService) GetFolders(limit int) ([]*m.Folder, error) { + return s.GetFoldersResult, s.GetFoldersError +} + +func (s *fakeFolderService) GetFolderByID(id int64) (*m.Folder, error) { + return s.GetFolderByIDResult, s.GetFolderByIDError +} + +func (s *fakeFolderService) GetFolderByUID(uid string) (*m.Folder, error) { + return s.GetFolderByUIDResult, s.GetFolderByUIDError +} + +func (s *fakeFolderService) CreateFolder(cmd *m.CreateFolderCommand) error { + cmd.Result = s.CreateFolderResult + return s.CreateFolderError +} + +func (s *fakeFolderService) UpdateFolder(existingUID string, cmd *m.UpdateFolderCommand) error { + cmd.Result = s.UpdateFolderResult + return s.UpdateFolderError +} + +func (s *fakeFolderService) DeleteFolder(uid string) (*m.Folder, error) { + s.DeletedFolderUids = append(s.DeletedFolderUids, uid) + return s.DeleteFolderResult, s.DeleteFolderError +} + +func mockFolderService(mock *fakeFolderService) { + dashboards.NewFolderService = func(orgId int64, user *m.SignedInUser) dashboards.FolderService { + return mock + } +} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 591dcc62344..5cd52122c3f 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -5,14 +5,13 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, error) { +func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { orgDataSources := make([]*m.DataSource, 0) if c.OrgId != 0 { @@ -180,7 +179,7 @@ func getPanelSort(id string) int { return sort } -func GetFrontendSettings(c *middleware.Context) { +func GetFrontendSettings(c *m.ReqContext) { settings, err := getFrontendSettingsMap(c) if err != nil { c.JsonApiErr(400, "Failed to get frontend settings", err) diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go index a2a446b48eb..afd3bb9bf8e 100644 --- a/pkg/api/grafana_com_proxy.go +++ b/pkg/api/grafana_com_proxy.go @@ -7,7 +7,7 @@ import ( "net/url" "time" - "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +41,7 @@ func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy { return &httputil.ReverseProxy{Director: director} } -func ProxyGnetRequest(c *middleware.Context) { +func ProxyGnetRequest(c *m.ReqContext) { proxyPath := c.Params("*") proxy := ReverseProxyGnetReq(proxyPath) proxy.Transport = grafanaComProxyTransport diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b911780913d..387a543ca89 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -29,7 +29,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -type HttpServer struct { +type HTTPServer struct { log log.Logger macaron *macaron.Macaron context context.Context @@ -39,14 +39,14 @@ type HttpServer struct { httpSrv *http.Server } -func NewHttpServer() *HttpServer { - return &HttpServer{ +func NewHTTPServer() *HTTPServer { + return &HTTPServer{ log: log.New("http.server"), cache: gocache.New(5*time.Minute, 10*time.Minute), } } -func (hs *HttpServer) Start(ctx context.Context) error { +func (hs *HTTPServer) Start(ctx context.Context) error { var err error hs.context = ctx @@ -74,12 +74,15 @@ func (hs *HttpServer) Start(ctx context.Context) error { return nil } case setting.SOCKET: - ln, err := net.Listen("unix", setting.SocketPath) + ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: setting.SocketPath, Net: "unix"}) if err != nil { hs.log.Debug("server was shutdown gracefully") return nil } + // Make socket writable by group + os.Chmod(setting.SocketPath, 0660) + err = hs.httpSrv.Serve(ln) if err != nil { hs.log.Debug("server was shutdown gracefully") @@ -93,13 +96,13 @@ func (hs *HttpServer) Start(ctx context.Context) error { return err } -func (hs *HttpServer) Shutdown(ctx context.Context) error { +func (hs *HTTPServer) Shutdown(ctx context.Context) error { err := hs.httpSrv.Shutdown(ctx) hs.log.Info("Stopped HTTP server") return err } -func (hs *HttpServer) listenAndServeTLS(certfile, keyfile string) error { +func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error { if certfile == "" { return fmt.Errorf("cert_file cannot be empty when using HTTPS") } @@ -141,7 +144,7 @@ func (hs *HttpServer) listenAndServeTLS(certfile, keyfile string) error { return hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) } -func (hs *HttpServer) newMacaron() *macaron.Macaron { +func (hs *HTTPServer) newMacaron() *macaron.Macaron { macaron.Env = setting.Env m := macaron.New() @@ -175,7 +178,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { m.Use(hs.healthHandler) m.Use(hs.metricsEndpoint) m.Use(middleware.GetContextHandler()) - m.Use(middleware.Sessioner(&setting.SessionOptions)) + m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime)) m.Use(middleware.OrgRedirect()) // needs to be after context handler @@ -188,7 +191,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { return m } -func (hs *HttpServer) metricsEndpoint(ctx *macaron.Context) { +func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { if ctx.Req.Method != "GET" || ctx.Req.URL.Path != "/metrics" { return } @@ -197,7 +200,7 @@ func (hs *HttpServer) metricsEndpoint(ctx *macaron.Context) { ServeHTTP(ctx.Resp, ctx.Req.Request) } -func (hs *HttpServer) healthHandler(ctx *macaron.Context) { +func (hs *HTTPServer) healthHandler(ctx *macaron.Context) { notHeadOrGet := ctx.Req.Method != http.MethodGet && ctx.Req.Method != http.MethodHead if notHeadOrGet || ctx.Req.URL.Path != "/api/health" { return @@ -221,7 +224,7 @@ func (hs *HttpServer) healthHandler(ctx *macaron.Context) { ctx.Resp.Write(dataBytes) } -func (hs *HttpServer) mapStatic(m *macaron.Macaron, rootDir string, dir string, prefix string) { +func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string, prefix string) { headers := func(c *macaron.Context) { c.Resp.Header().Set("Cache-Control", "public, max-age=3600") } diff --git a/pkg/api/index.go b/pkg/api/index.go index 5beecefab88..a1d21d1c686 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -6,13 +6,12 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" ) -func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { +func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { settings, err := getFrontendSettingsMap(c) if err != nil { return nil, err @@ -33,13 +32,13 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { locale = parts[0] } - appUrl := setting.AppUrl - appSubUrl := setting.AppSubUrl + appURL := setting.AppUrl + appSubURL := setting.AppSubUrl // special case when doing localhost call from phantomjs if c.IsRenderCall { - appUrl = fmt.Sprintf("%s://localhost:%s", setting.Protocol, setting.HttpPort) - appSubUrl = "" + appURL = fmt.Sprintf("%s://localhost:%s", setting.Protocol, setting.HttpPort) + appSubURL = "" settings["appSubUrl"] = "" } @@ -63,8 +62,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { }, Settings: settings, Theme: prefs.Theme, - AppUrl: appUrl, - AppSubUrl: appSubUrl, + AppUrl: appURL, + AppSubUrl: appSubURL, GoogleAnalyticsId: setting.GoogleAnalyticsId, GoogleTagManagerId: setting.GoogleTagManagerId, BuildVersion: setting.BuildVersion, @@ -74,15 +73,15 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { } if setting.DisableGravatar { - data.User.GravatarUrl = setting.AppSubUrl + "/public/img/transparent.png" + data.User.GravatarUrl = setting.AppSubUrl + "/public/img/user_profile.png" } if len(data.User.Name) == 0 { data.User.Name = data.User.Login } - themeUrlParam := c.Query("theme") - if themeUrlParam == "light" { + themeURLParam := c.Query("theme") + if themeURLParam == "light" { data.User.LightTheme = true data.Theme = "light" } @@ -299,25 +298,26 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { return &data, nil } -func Index(c *middleware.Context) { - if data, err := setIndexViewData(c); err != nil { +func Index(c *m.ReqContext) { + data, err := setIndexViewData(c) + if err != nil { c.Handle(500, "Failed to get settings", err) return - } else { - c.HTML(200, "index", data) } + c.HTML(200, "index", data) } -func NotFoundHandler(c *middleware.Context) { +func NotFoundHandler(c *m.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(404, "Not found", nil) return } - if data, err := setIndexViewData(c); err != nil { + data, err := setIndexViewData(c) + if err != nil { c.Handle(500, "Failed to get settings", err) return - } else { - c.HTML(404, "index", data) } + + c.HTML(404, "index", data) } diff --git a/pkg/api/login.go b/pkg/api/login.go index b6855af7baf..671e5fb7ecd 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -8,16 +8,16 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" ) const ( - VIEW_INDEX = "index" + ViewIndex = "index" ) -func LoginView(c *middleware.Context) { +func LoginView(c *m.ReqContext) { viewData, err := setIndexViewData(c) if err != nil { c.Handle(500, "Failed to get settings", err) @@ -40,7 +40,7 @@ func LoginView(c *middleware.Context) { } if !tryLoginUsingRememberCookie(c) { - c.HTML(200, VIEW_INDEX, viewData) + c.HTML(200, ViewIndex, viewData) return } @@ -53,7 +53,7 @@ func LoginView(c *middleware.Context) { c.Redirect(setting.AppSubUrl + "/") } -func tryLoginUsingRememberCookie(c *middleware.Context) bool { +func tryLoginUsingRememberCookie(c *m.ReqContext) bool { // Check auto-login. uname := c.GetCookie(setting.CookieUserName) if len(uname) == 0 { @@ -87,7 +87,7 @@ func tryLoginUsingRememberCookie(c *middleware.Context) bool { return true } -func LoginApiPing(c *middleware.Context) { +func LoginAPIPing(c *m.ReqContext) { if !tryLoginUsingRememberCookie(c) { c.JsonApiErr(401, "Unauthorized", nil) return @@ -96,9 +96,9 @@ func LoginApiPing(c *middleware.Context) { c.JsonOK("Logged in") } -func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { +func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { if setting.DisableLoginForm { - return ApiError(401, "Login is disabled", nil) + return Error(401, "Login is disabled", nil) } authQuery := login.LoginUserQuery{ @@ -109,10 +109,10 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { if err := bus.Dispatch(&authQuery); err != nil { if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { - return ApiError(401, "Invalid username or password", err) + return Error(401, "Invalid username or password", err) } - return ApiError(500, "Error while trying to authenticate user", err) + return Error(500, "Error while trying to authenticate user", err) } user := authQuery.User @@ -130,10 +130,10 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { metrics.M_Api_Login_Post.Inc() - return Json(200, result) + return JSON(200, result) } -func loginUserWithUser(user *m.User, c *middleware.Context) { +func loginUserWithUser(user *m.User, c *m.ReqContext) { if user == nil { log.Error(3, "User login with nil user") } @@ -146,13 +146,13 @@ func loginUserWithUser(user *m.User, c *middleware.Context) { c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/") } - c.Session.RegenerateId(c) - c.Session.Set(middleware.SESS_KEY_USERID, user.Id) + c.Session.RegenerateId(c.Context) + c.Session.Set(session.SESS_KEY_USERID, user.Id) } -func Logout(c *middleware.Context) { +func Logout(c *m.ReqContext) { c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/") - c.Session.Destory(c) + c.Session.Destory(c.Context) c.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 341ff212f10..1dba38e9cbd 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -17,8 +17,9 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/social" ) @@ -38,7 +39,7 @@ func GenStateString() string { return base64.URLEncoding.EncodeToString(rnd) } -func OAuthLogin(ctx *middleware.Context) { +func OAuthLogin(ctx *m.ReqContext) { if setting.OAuthService == nil { ctx.Handle(404, "OAuth not enabled", nil) return @@ -62,7 +63,7 @@ func OAuthLogin(ctx *middleware.Context) { code := ctx.Query("code") if code == "" { state := GenStateString() - ctx.Session.Set(middleware.SESS_KEY_OAUTH_STATE, state) + ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state) if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) } else { @@ -71,7 +72,7 @@ func OAuthLogin(ctx *middleware.Context) { return } - savedState, ok := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string) + savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string) if !ok { ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) return @@ -167,7 +168,7 @@ func OAuthLogin(ctx *middleware.Context) { redirectWithError(ctx, ErrSignUpNotAllowed) return } - limitReached, err := middleware.QuotaReached(ctx, "user") + limitReached, err := quota.QuotaReached(ctx, "user") if err != nil { ctx.Handle(500, "Failed to get user quota", err) return @@ -208,7 +209,7 @@ func OAuthLogin(ctx *middleware.Context) { ctx.Redirect(setting.AppSubUrl + "/") } -func redirectWithError(ctx *middleware.Context, err error, v ...interface{}) { +func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { ctx.Logger.Error(err.Error(), v...) ctx.Session.Set("loginError", err.Error()) ctx.Redirect(setting.AppSubUrl + "/login") diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 717565cd4a1..5c06d652b70 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -6,29 +6,28 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/tsdb/testdata" "github.com/grafana/grafana/pkg/util" ) // POST /api/tsdb/query -func QueryMetrics(c *middleware.Context, reqDto dtos.MetricRequest) Response { +func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response { timeRange := tsdb.NewTimeRange(reqDto.From, reqDto.To) if len(reqDto.Queries) == 0 { - return ApiError(400, "No queries found in query", nil) + return Error(400, "No queries found in query", nil) } - dsId, err := reqDto.Queries[0].Get("datasourceId").Int64() + dsID, err := reqDto.Queries[0].Get("datasourceId").Int64() if err != nil { - return ApiError(400, "Query missing datasourceId", nil) + return Error(400, "Query missing datasourceId", nil) } - dsQuery := models.GetDataSourceByIdQuery{Id: dsId, OrgId: c.OrgId} + dsQuery := m.GetDataSourceByIdQuery{Id: dsID, OrgId: c.OrgId} if err := bus.Dispatch(&dsQuery); err != nil { - return ApiError(500, "failed to fetch data source", err) + return Error(500, "failed to fetch data source", err) } request := &tsdb.TsdbQuery{TimeRange: timeRange} @@ -45,7 +44,7 @@ func QueryMetrics(c *middleware.Context, reqDto dtos.MetricRequest) Response { resp, err := tsdb.HandleRequest(context.Background(), dsQuery.Result, request) if err != nil { - return ApiError(500, "Metric request error", err) + return Error(500, "Metric request error", err) } statusCode := 200 @@ -57,11 +56,11 @@ func QueryMetrics(c *middleware.Context, reqDto dtos.MetricRequest) Response { } } - return Json(statusCode, &resp) + return JSON(statusCode, &resp) } // GET /api/tsdb/testdata/scenarios -func GetTestDataScenarios(c *middleware.Context) Response { +func GetTestDataScenarios(c *m.ReqContext) Response { result := make([]interface{}, 0) for _, scenario := range testdata.ScenarioRegistry { @@ -73,26 +72,26 @@ func GetTestDataScenarios(c *middleware.Context) Response { }) } - return Json(200, &result) + return JSON(200, &result) } // Genereates a index out of range error -func GenerateError(c *middleware.Context) Response { +func GenerateError(c *m.ReqContext) Response { var array []string - return Json(200, array[20]) + return JSON(200, array[20]) } // GET /api/tsdb/testdata/gensql -func GenerateSqlTestData(c *middleware.Context) Response { - if err := bus.Dispatch(&models.InsertSqlTestDataCommand{}); err != nil { - return ApiError(500, "Failed to insert test data", err) +func GenerateSQLTestData(c *m.ReqContext) Response { + if err := bus.Dispatch(&m.InsertSqlTestDataCommand{}); err != nil { + return Error(500, "Failed to insert test data", err) } - return Json(200, &util.DynMap{"message": "OK"}) + return JSON(200, &util.DynMap{"message": "OK"}) } // GET /api/tsdb/testdata/random-walk -func GetTestDataRandomWalk(c *middleware.Context) Response { +func GetTestDataRandomWalk(c *m.ReqContext) Response { from := c.Query("from") to := c.Query("to") intervalMs := c.QueryInt64("intervalMs") @@ -100,7 +99,7 @@ func GetTestDataRandomWalk(c *middleware.Context) Response { timeRange := tsdb.NewTimeRange(from, to) request := &tsdb.TsdbQuery{TimeRange: timeRange} - dsInfo := &models.DataSource{Type: "grafana-testdata-datasource"} + dsInfo := &m.DataSource{Type: "grafana-testdata-datasource"} request.Queries = append(request.Queries, &tsdb.Query{ RefId: "A", IntervalMs: intervalMs, @@ -112,8 +111,8 @@ func GetTestDataRandomWalk(c *middleware.Context) Response { resp, err := tsdb.HandleRequest(context.Background(), dsInfo, request) if err != nil { - return ApiError(500, "Metric request error", err) + return Error(500, "Metric request error", err) } - return Json(200, &resp) + return JSON(200, &resp) } diff --git a/pkg/api/org.go b/pkg/api/org.go index bddfebf80ce..8d0d48bad13 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -4,31 +4,30 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) // GET /api/org -func GetOrgCurrent(c *middleware.Context) Response { +func GetOrgCurrent(c *m.ReqContext) Response { return getOrgHelper(c.OrgId) } // GET /api/orgs/:orgId -func GetOrgById(c *middleware.Context) Response { +func GetOrgByID(c *m.ReqContext) Response { return getOrgHelper(c.ParamsInt64(":orgId")) } // Get /api/orgs/name/:name -func GetOrgByName(c *middleware.Context) Response { +func GetOrgByName(c *m.ReqContext) Response { query := m.GetOrgByNameQuery{Name: c.Params(":name")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrOrgNotFound { - return ApiError(404, "Organization not found", err) + return Error(404, "Organization not found", err) } - return ApiError(500, "Failed to get organization", err) + return Error(500, "Failed to get organization", err) } org := query.Result result := m.OrgDetailsDTO{ @@ -44,18 +43,18 @@ func GetOrgByName(c *middleware.Context) Response { }, } - return Json(200, &result) + return JSON(200, &result) } -func getOrgHelper(orgId int64) Response { - query := m.GetOrgByIdQuery{Id: orgId} +func getOrgHelper(orgID int64) Response { + query := m.GetOrgByIdQuery{Id: orgID} if err := bus.Dispatch(&query); err != nil { if err == m.ErrOrgNotFound { - return ApiError(404, "Organization not found", err) + return Error(404, "Organization not found", err) } - return ApiError(500, "Failed to get organization", err) + return Error(500, "Failed to get organization", err) } org := query.Result @@ -72,66 +71,66 @@ func getOrgHelper(orgId int64) Response { }, } - return Json(200, &result) + return JSON(200, &result) } // POST /api/orgs -func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) Response { +func CreateOrg(c *m.ReqContext, cmd m.CreateOrgCommand) Response { if !c.IsSignedIn || (!setting.AllowUserOrgCreate && !c.IsGrafanaAdmin) { - return ApiError(403, "Access denied", nil) + return Error(403, "Access denied", nil) } cmd.UserId = c.UserId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrOrgNameTaken { - return ApiError(409, "Organization name taken", err) + return Error(409, "Organization name taken", err) } - return ApiError(500, "Failed to create organization", err) + return Error(500, "Failed to create organization", err) } metrics.M_Api_Org_Create.Inc() - return Json(200, &util.DynMap{ + return JSON(200, &util.DynMap{ "orgId": cmd.Result.Id, "message": "Organization created", }) } // PUT /api/org -func UpdateOrgCurrent(c *middleware.Context, form dtos.UpdateOrgForm) Response { +func UpdateOrgCurrent(c *m.ReqContext, form dtos.UpdateOrgForm) Response { return updateOrgHelper(form, c.OrgId) } // PUT /api/orgs/:orgId -func UpdateOrg(c *middleware.Context, form dtos.UpdateOrgForm) Response { +func UpdateOrg(c *m.ReqContext, form dtos.UpdateOrgForm) Response { return updateOrgHelper(form, c.ParamsInt64(":orgId")) } -func updateOrgHelper(form dtos.UpdateOrgForm, orgId int64) Response { - cmd := m.UpdateOrgCommand{Name: form.Name, OrgId: orgId} +func updateOrgHelper(form dtos.UpdateOrgForm, orgID int64) Response { + cmd := m.UpdateOrgCommand{Name: form.Name, OrgId: orgID} if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrOrgNameTaken { - return ApiError(400, "Organization name taken", err) + return Error(400, "Organization name taken", err) } - return ApiError(500, "Failed to update organization", err) + return Error(500, "Failed to update organization", err) } - return ApiSuccess("Organization updated") + return Success("Organization updated") } // PUT /api/org/address -func UpdateOrgAddressCurrent(c *middleware.Context, form dtos.UpdateOrgAddressForm) Response { +func UpdateOrgAddressCurrent(c *m.ReqContext, form dtos.UpdateOrgAddressForm) Response { return updateOrgAddressHelper(form, c.OrgId) } // PUT /api/orgs/:orgId/address -func UpdateOrgAddress(c *middleware.Context, form dtos.UpdateOrgAddressForm) Response { +func UpdateOrgAddress(c *m.ReqContext, form dtos.UpdateOrgAddressForm) Response { return updateOrgAddressHelper(form, c.ParamsInt64(":orgId")) } -func updateOrgAddressHelper(form dtos.UpdateOrgAddressForm, orgId int64) Response { +func updateOrgAddressHelper(form dtos.UpdateOrgAddressForm, orgID int64) Response { cmd := m.UpdateOrgAddressCommand{ - OrgId: orgId, + OrgId: orgID, Address: m.Address{ Address1: form.Address1, Address2: form.Address2, @@ -143,24 +142,24 @@ func updateOrgAddressHelper(form dtos.UpdateOrgAddressForm, orgId int64) Respons } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update org address", err) + return Error(500, "Failed to update org address", err) } - return ApiSuccess("Address updated") + return Success("Address updated") } // GET /api/orgs/:orgId -func DeleteOrgById(c *middleware.Context) Response { +func DeleteOrgByID(c *m.ReqContext) Response { if err := bus.Dispatch(&m.DeleteOrgCommand{Id: c.ParamsInt64(":orgId")}); err != nil { if err == m.ErrOrgNotFound { - return ApiError(404, "Failed to delete organization. ID not found", nil) + return Error(404, "Failed to delete organization. ID not found", nil) } - return ApiError(500, "Failed to update organization", err) + return Error(500, "Failed to update organization", err) } - return ApiSuccess("Organization deleted") + return Success("Organization deleted") } -func SearchOrgs(c *middleware.Context) Response { +func SearchOrgs(c *m.ReqContext) Response { query := m.SearchOrgsQuery{ Query: c.Query("query"), Name: c.Query("name"), @@ -169,8 +168,8 @@ func SearchOrgs(c *middleware.Context) Response { } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to search orgs", err) + return Error(500, "Failed to search orgs", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 57d9913d2eb..d6ab1c9d372 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -7,40 +7,39 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -func GetPendingOrgInvites(c *middleware.Context) Response { +func GetPendingOrgInvites(c *m.ReqContext) Response { query := m.GetTempUsersQuery{OrgId: c.OrgId, Status: m.TmpUserInvitePending} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to get invites from db", err) + return Error(500, "Failed to get invites from db", err) } for _, invite := range query.Result { invite.Url = setting.ToAbsUrl("invite/" + invite.Code) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response { +func AddOrgInvite(c *m.ReqContext, inviteDto dtos.AddInviteForm) Response { if !inviteDto.Role.IsValid() { - return ApiError(400, "Invalid role specified", nil) + return Error(400, "Invalid role specified", nil) } // first try get existing user userQuery := m.GetUserByLoginQuery{LoginOrEmail: inviteDto.LoginOrEmail} if err := bus.Dispatch(&userQuery); err != nil { if err != m.ErrUserNotFound { - return ApiError(500, "Failed to query db for existing user check", err) + return Error(500, "Failed to query db for existing user check", err) } if setting.DisableLoginForm { - return ApiError(401, "User could not be found", nil) + return Error(401, "User could not be found", nil) } } else { return inviteExistingUserToOrg(c, userQuery.Result, &inviteDto) @@ -57,7 +56,7 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response cmd.RemoteAddr = c.Req.RemoteAddr if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to save invite to database", err) + return Error(500, "Failed to save invite to database", err) } // send invite email @@ -75,71 +74,70 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response } if err := bus.Dispatch(&emailCmd); err != nil { - return ApiError(500, "Failed to send email invite", err) + return Error(500, "Failed to send email invite", err) } emailSentCmd := m.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code} if err := bus.Dispatch(&emailSentCmd); err != nil { - return ApiError(500, "Failed to update invite with email sent info", err) + return Error(500, "Failed to update invite with email sent info", err) } - return ApiSuccess(fmt.Sprintf("Sent invite to %s", inviteDto.LoginOrEmail)) + return Success(fmt.Sprintf("Sent invite to %s", inviteDto.LoginOrEmail)) } - return ApiSuccess(fmt.Sprintf("Created invite for %s", inviteDto.LoginOrEmail)) + return Success(fmt.Sprintf("Created invite for %s", inviteDto.LoginOrEmail)) } -func inviteExistingUserToOrg(c *middleware.Context, user *m.User, inviteDto *dtos.AddInviteForm) Response { +func inviteExistingUserToOrg(c *m.ReqContext, user *m.User, inviteDto *dtos.AddInviteForm) Response { // user exists, add org role createOrgUserCmd := m.AddOrgUserCommand{OrgId: c.OrgId, UserId: user.Id, Role: inviteDto.Role} if err := bus.Dispatch(&createOrgUserCmd); err != nil { if err == m.ErrOrgUserAlreadyAdded { - return ApiError(412, fmt.Sprintf("User %s is already added to organization", inviteDto.LoginOrEmail), err) + return Error(412, fmt.Sprintf("User %s is already added to organization", inviteDto.LoginOrEmail), err) } - return ApiError(500, "Error while trying to create org user", err) - } else { - - if inviteDto.SendEmail && util.IsEmail(user.Email) { - emailCmd := m.SendEmailCommand{ - To: []string{user.Email}, - Template: "invited_to_org.html", - Data: map[string]interface{}{ - "Name": user.NameOrFallback(), - "OrgName": c.OrgName, - "InvitedBy": util.StringsFallback3(c.Name, c.Email, c.Login), - }, - } - - if err := bus.Dispatch(&emailCmd); err != nil { - return ApiError(500, "Failed to send email invited_to_org", err) - } - } - - return ApiSuccess(fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName)) + return Error(500, "Error while trying to create org user", err) } + + if inviteDto.SendEmail && util.IsEmail(user.Email) { + emailCmd := m.SendEmailCommand{ + To: []string{user.Email}, + Template: "invited_to_org.html", + Data: map[string]interface{}{ + "Name": user.NameOrFallback(), + "OrgName": c.OrgName, + "InvitedBy": util.StringsFallback3(c.Name, c.Email, c.Login), + }, + } + + if err := bus.Dispatch(&emailCmd); err != nil { + return Error(500, "Failed to send email invited_to_org", err) + } + } + + return Success(fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName)) } -func RevokeInvite(c *middleware.Context) Response { +func RevokeInvite(c *m.ReqContext) Response { if ok, rsp := updateTempUserStatus(c.Params(":code"), m.TmpUserRevoked); !ok { return rsp } - return ApiSuccess("Invite revoked") + return Success("Invite revoked") } -func GetInviteInfoByCode(c *middleware.Context) Response { +func GetInviteInfoByCode(c *m.ReqContext) Response { query := m.GetTempUserByCodeQuery{Code: c.Params(":code")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrTempUserNotFound { - return ApiError(404, "Invite not found", nil) + return Error(404, "Invite not found", nil) } - return ApiError(500, "Failed to get invite", err) + return Error(500, "Failed to get invite", err) } invite := query.Result - return Json(200, dtos.InviteInfo{ + return JSON(200, dtos.InviteInfo{ Email: invite.Email, Name: invite.Name, Username: invite.Email, @@ -147,19 +145,19 @@ func GetInviteInfoByCode(c *middleware.Context) Response { }) } -func CompleteInvite(c *middleware.Context, completeInvite dtos.CompleteInviteForm) Response { +func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} if err := bus.Dispatch(&query); err != nil { if err == m.ErrTempUserNotFound { - return ApiError(404, "Invite not found", nil) + return Error(404, "Invite not found", nil) } - return ApiError(500, "Failed to get invite", err) + return Error(500, "Failed to get invite", err) } invite := query.Result if invite.Status != m.TmpUserInvitePending { - return ApiError(412, fmt.Sprintf("Invite cannot be used in status %s", invite.Status), nil) + return Error(412, fmt.Sprintf("Invite cannot be used in status %s", invite.Status), nil) } cmd := m.CreateUserCommand{ @@ -171,7 +169,7 @@ func CompleteInvite(c *middleware.Context, completeInvite dtos.CompleteInviteFor } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "failed to create user", err) + return Error(500, "failed to create user", err) } user := &cmd.Result @@ -190,14 +188,14 @@ func CompleteInvite(c *middleware.Context, completeInvite dtos.CompleteInviteFor metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpInvite.Inc() - return ApiSuccess("User created and logged in") + return Success("User created and logged in") } func updateTempUserStatus(code string, status m.TempUserStatus) (bool, Response) { // update temp user status updateTmpUserCmd := m.UpdateTempUserStatusCommand{Code: code, Status: status} if err := bus.Dispatch(&updateTmpUserCmd); err != nil { - return false, ApiError(500, "Failed to update invite status", err) + return false, Error(500, "Failed to update invite status", err) } return true, nil @@ -208,7 +206,7 @@ func applyUserInvite(user *m.User, invite *m.TempUserDTO, setActive bool) (bool, addOrgUserCmd := m.AddOrgUserCommand{OrgId: invite.OrgId, UserId: user.Id, Role: invite.Role} if err := bus.Dispatch(&addOrgUserCmd); err != nil { if err != m.ErrOrgUserAlreadyAdded { - return false, ApiError(500, "Error while trying to create org user", err) + return false, Error(500, "Error while trying to create org user", err) } } @@ -220,7 +218,7 @@ func applyUserInvite(user *m.User, invite *m.TempUserDTO, setActive bool) (bool, if setActive { // set org to active if err := bus.Dispatch(&m.SetUsingOrgCommand{OrgId: invite.OrgId, UserId: user.Id}); err != nil { - return false, ApiError(500, "Failed to set org as active", err) + return false, Error(500, "Failed to set org as active", err) } } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 433b9f2bd66..4e2ed36431e 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -3,31 +3,30 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) // POST /api/org/users -func AddOrgUserToCurrentOrg(c *middleware.Context, cmd m.AddOrgUserCommand) Response { +func AddOrgUserToCurrentOrg(c *m.ReqContext, cmd m.AddOrgUserCommand) Response { cmd.OrgId = c.OrgId return addOrgUserHelper(cmd) } // POST /api/orgs/:orgId/users -func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) Response { +func AddOrgUser(c *m.ReqContext, cmd m.AddOrgUserCommand) Response { cmd.OrgId = c.ParamsInt64(":orgId") return addOrgUserHelper(cmd) } func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { if !cmd.Role.IsValid() { - return ApiError(400, "Invalid role specified", nil) + return Error(400, "Invalid role specified", nil) } userQuery := m.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail} err := bus.Dispatch(&userQuery) if err != nil { - return ApiError(404, "User not found", nil) + return Error(404, "User not found", nil) } userToAdd := userQuery.Result @@ -36,51 +35,51 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrOrgUserAlreadyAdded { - return ApiError(409, "User is already member of this organization", nil) + return Error(409, "User is already member of this organization", nil) } - return ApiError(500, "Could not add user to organization", err) + return Error(500, "Could not add user to organization", err) } - return ApiSuccess("User added to organization") + return Success("User added to organization") } // GET /api/org/users -func GetOrgUsersForCurrentOrg(c *middleware.Context) Response { +func GetOrgUsersForCurrentOrg(c *m.ReqContext) Response { return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit")) } // GET /api/orgs/:orgId/users -func GetOrgUsers(c *middleware.Context) Response { +func GetOrgUsers(c *m.ReqContext) Response { return getOrgUsersHelper(c.ParamsInt64(":orgId"), "", 0) } -func getOrgUsersHelper(orgId int64, query string, limit int) Response { +func getOrgUsersHelper(orgID int64, query string, limit int) Response { q := m.GetOrgUsersQuery{ - OrgId: orgId, + OrgId: orgID, Query: query, Limit: limit, } if err := bus.Dispatch(&q); err != nil { - return ApiError(500, "Failed to get account user", err) + return Error(500, "Failed to get account user", err) } for _, user := range q.Result { user.AvatarUrl = dtos.GetGravatarUrl(user.Email) } - return Json(200, q.Result) + return JSON(200, q.Result) } // PATCH /api/org/users/:userId -func UpdateOrgUserForCurrentOrg(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { +func UpdateOrgUserForCurrentOrg(c *m.ReqContext, cmd m.UpdateOrgUserCommand) Response { cmd.OrgId = c.OrgId cmd.UserId = c.ParamsInt64(":userId") return updateOrgUserHelper(cmd) } // PATCH /api/orgs/:orgId/users/:userId -func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { +func UpdateOrgUser(c *m.ReqContext, cmd m.UpdateOrgUserCommand) Response { cmd.OrgId = c.ParamsInt64(":orgId") cmd.UserId = c.ParamsInt64(":userId") return updateOrgUserHelper(cmd) @@ -88,41 +87,41 @@ func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response { if !cmd.Role.IsValid() { - return ApiError(400, "Invalid role specified", nil) + return Error(400, "Invalid role specified", nil) } if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrLastOrgAdmin { - return ApiError(400, "Cannot change role so that there is no organization admin left", nil) + return Error(400, "Cannot change role so that there is no organization admin left", nil) } - return ApiError(500, "Failed update org user", err) + return Error(500, "Failed update org user", err) } - return ApiSuccess("Organization user updated") + return Success("Organization user updated") } // DELETE /api/org/users/:userId -func RemoveOrgUserForCurrentOrg(c *middleware.Context) Response { - userId := c.ParamsInt64(":userId") - return removeOrgUserHelper(c.OrgId, userId) +func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response { + userID := c.ParamsInt64(":userId") + return removeOrgUserHelper(c.OrgId, userID) } // DELETE /api/orgs/:orgId/users/:userId -func RemoveOrgUser(c *middleware.Context) Response { - userId := c.ParamsInt64(":userId") - orgId := c.ParamsInt64(":orgId") - return removeOrgUserHelper(orgId, userId) +func RemoveOrgUser(c *m.ReqContext) Response { + userID := c.ParamsInt64(":userId") + orgID := c.ParamsInt64(":orgId") + return removeOrgUserHelper(orgID, userID) } -func removeOrgUserHelper(orgId int64, userId int64) Response { - cmd := m.RemoveOrgUserCommand{OrgId: orgId, UserId: userId} +func removeOrgUserHelper(orgID int64, userID int64) Response { + cmd := m.RemoveOrgUserCommand{OrgId: orgID, UserId: userID} if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrLastOrgAdmin { - return ApiError(400, "Cannot remove last organization admin", nil) + return Error(400, "Cannot remove last organization admin", nil) } - return ApiError(500, "Failed to remove user from organization", err) + return Error(500, "Failed to remove user from organization", err) } - return ApiSuccess("User removed from organization") + return Success("User removed from organization") } diff --git a/pkg/api/password.go b/pkg/api/password.go index e71f1317ee4..7dd901c898e 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -3,39 +3,38 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) -func SendResetPasswordEmail(c *middleware.Context, form dtos.SendResetPasswordEmailForm) Response { +func SendResetPasswordEmail(c *m.ReqContext, form dtos.SendResetPasswordEmailForm) Response { userQuery := m.GetUserByLoginQuery{LoginOrEmail: form.UserOrEmail} if err := bus.Dispatch(&userQuery); err != nil { c.Logger.Info("Requested password reset for user that was not found", "user", userQuery.LoginOrEmail) - return ApiError(200, "Email sent", err) + return Error(200, "Email sent", err) } emailCmd := m.SendResetPasswordEmailCommand{User: userQuery.Result} if err := bus.Dispatch(&emailCmd); err != nil { - return ApiError(500, "Failed to send email", err) + return Error(500, "Failed to send email", err) } - return ApiSuccess("Email sent") + return Success("Email sent") } -func ResetPassword(c *middleware.Context, form dtos.ResetUserPasswordForm) Response { +func ResetPassword(c *m.ReqContext, form dtos.ResetUserPasswordForm) Response { query := m.ValidateResetPasswordCodeQuery{Code: form.Code} if err := bus.Dispatch(&query); err != nil { if err == m.ErrInvalidEmailCode { - return ApiError(400, "Invalid or expired reset password code", nil) + return Error(400, "Invalid or expired reset password code", nil) } - return ApiError(500, "Unknown error validating email code", err) + return Error(500, "Unknown error validating email code", err) } if form.NewPassword != form.ConfirmPassword { - return ApiError(400, "Passwords do not match", nil) + return Error(400, "Passwords do not match", nil) } cmd := m.ChangeUserPasswordCommand{} @@ -43,8 +42,8 @@ func ResetPassword(c *middleware.Context, form dtos.ResetUserPasswordForm) Respo cmd.NewPassword = util.EncodePassword(form.NewPassword, query.Result.Salt) if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to change user password", err) + return Error(500, "Failed to change user password", err) } - return ApiSuccess("User password changed") + return Success("User password changed") } diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 040aef0474e..d2413dfbb4c 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -3,11 +3,10 @@ package api import ( "github.com/grafana/grafana/pkg/bus" _ "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) -func ValidateOrgPlaylist(c *middleware.Context) { +func ValidateOrgPlaylist(c *m.ReqContext) { id := c.ParamsInt64(":id") query := m.GetPlaylistByIdQuery{Id: id} err := bus.Dispatch(&query) @@ -40,7 +39,7 @@ func ValidateOrgPlaylist(c *middleware.Context) { } } -func SearchPlaylists(c *middleware.Context) Response { +func SearchPlaylists(c *m.ReqContext) Response { query := c.Query("query") limit := c.QueryInt("limit") @@ -56,18 +55,18 @@ func SearchPlaylists(c *middleware.Context) Response { err := bus.Dispatch(&searchQuery) if err != nil { - return ApiError(500, "Search failed", err) + return Error(500, "Search failed", err) } - return Json(200, searchQuery.Result) + return JSON(200, searchQuery.Result) } -func GetPlaylist(c *middleware.Context) Response { +func GetPlaylist(c *m.ReqContext) Response { id := c.ParamsInt64(":id") cmd := m.GetPlaylistByIdQuery{Id: id} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Playlist not found", err) + return Error(500, "Playlist not found", err) } playlistDTOs, _ := LoadPlaylistItemDTOs(id) @@ -80,7 +79,7 @@ func GetPlaylist(c *middleware.Context) Response { Items: playlistDTOs, } - return Json(200, dto) + return JSON(200, dto) } func LoadPlaylistItemDTOs(id int64) ([]m.PlaylistItemDTO, error) { @@ -115,62 +114,62 @@ func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) { return *itemQuery.Result, nil } -func GetPlaylistItems(c *middleware.Context) Response { +func GetPlaylistItems(c *m.ReqContext) Response { id := c.ParamsInt64(":id") playlistDTOs, err := LoadPlaylistItemDTOs(id) if err != nil { - return ApiError(500, "Could not load playlist items", err) + return Error(500, "Could not load playlist items", err) } - return Json(200, playlistDTOs) + return JSON(200, playlistDTOs) } -func GetPlaylistDashboards(c *middleware.Context) Response { - playlistId := c.ParamsInt64(":id") +func GetPlaylistDashboards(c *m.ReqContext) Response { + playlistID := c.ParamsInt64(":id") - playlists, err := LoadPlaylistDashboards(c.OrgId, c.SignedInUser, playlistId) + playlists, err := LoadPlaylistDashboards(c.OrgId, c.SignedInUser, playlistID) if err != nil { - return ApiError(500, "Could not load dashboards", err) + return Error(500, "Could not load dashboards", err) } - return Json(200, playlists) + return JSON(200, playlists) } -func DeletePlaylist(c *middleware.Context) Response { +func DeletePlaylist(c *m.ReqContext) Response { id := c.ParamsInt64(":id") cmd := m.DeletePlaylistCommand{Id: id, OrgId: c.OrgId} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete playlist", err) + return Error(500, "Failed to delete playlist", err) } - return Json(200, "") + return JSON(200, "") } -func CreatePlaylist(c *middleware.Context, cmd m.CreatePlaylistCommand) Response { +func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to create playlist", err) + return Error(500, "Failed to create playlist", err) } - return Json(200, cmd.Result) + return JSON(200, cmd.Result) } -func UpdatePlaylist(c *middleware.Context, cmd m.UpdatePlaylistCommand) Response { +func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to save playlist", err) + return Error(500, "Failed to save playlist", err) } playlistDTOs, err := LoadPlaylistItemDTOs(cmd.Id) if err != nil { - return ApiError(500, "Failed to save playlist", err) + return Error(500, "Failed to save playlist", err) } cmd.Result.Items = playlistDTOs - return Json(200, cmd.Result) + return JSON(200, cmd.Result) } diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index 1d059e06be5..e82c7b438b4 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -11,11 +11,11 @@ import ( "github.com/grafana/grafana/pkg/services/search" ) -func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]int) (dtos.PlaylistDashboardsSlice, error) { +func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]int) (dtos.PlaylistDashboardsSlice, error) { result := make(dtos.PlaylistDashboardsSlice, 0) - if len(dashboardByIds) > 0 { - dashboardQuery := m.GetDashboardsQuery{DashboardIds: dashboardByIds} + if len(dashboardByIDs) > 0 { + dashboardQuery := m.GetDashboardsQuery{DashboardIds: dashboardByIDs} if err := bus.Dispatch(&dashboardQuery); err != nil { return result, err } @@ -26,7 +26,7 @@ func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]i Slug: item.Slug, Title: item.Title, Uri: "db/" + item.Slug, - Order: dashboardIdOrder[item.Id], + Order: dashboardIDOrder[item.Id], }) } } @@ -34,29 +34,27 @@ func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]i return result, nil } -func populateDashboardsByTag(orgId int64, signedInUser *m.SignedInUser, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice { +func populateDashboardsByTag(orgID int64, signedInUser *m.SignedInUser, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice { result := make(dtos.PlaylistDashboardsSlice, 0) - if len(dashboardByTag) > 0 { - for _, tag := range dashboardByTag { - searchQuery := search.Query{ - Title: "", - Tags: []string{tag}, - SignedInUser: signedInUser, - Limit: 100, - IsStarred: false, - OrgId: orgId, - } + for _, tag := range dashboardByTag { + searchQuery := search.Query{ + Title: "", + Tags: []string{tag}, + SignedInUser: signedInUser, + Limit: 100, + IsStarred: false, + OrgId: orgID, + } - if err := bus.Dispatch(&searchQuery); err == nil { - for _, item := range searchQuery.Result { - result = append(result, dtos.PlaylistDashboard{ - Id: item.Id, - Title: item.Title, - Uri: item.Uri, - Order: dashboardTagOrder[tag], - }) - } + if err := bus.Dispatch(&searchQuery); err == nil { + for _, item := range searchQuery.Result { + result = append(result, dtos.PlaylistDashboard{ + Id: item.Id, + Title: item.Title, + Uri: item.Uri, + Order: dashboardTagOrder[tag], + }) } } } @@ -64,19 +62,19 @@ func populateDashboardsByTag(orgId int64, signedInUser *m.SignedInUser, dashboar return result } -func LoadPlaylistDashboards(orgId int64, signedInUser *m.SignedInUser, playlistId int64) (dtos.PlaylistDashboardsSlice, error) { - playlistItems, _ := LoadPlaylistItems(playlistId) +func LoadPlaylistDashboards(orgID int64, signedInUser *m.SignedInUser, playlistID int64) (dtos.PlaylistDashboardsSlice, error) { + playlistItems, _ := LoadPlaylistItems(playlistID) - dashboardByIds := make([]int64, 0) + dashboardByIDs := make([]int64, 0) dashboardByTag := make([]string, 0) - dashboardIdOrder := make(map[int64]int) + dashboardIDOrder := make(map[int64]int) dashboardTagOrder := make(map[string]int) for _, i := range playlistItems { if i.Type == "dashboard_by_id" { - dashboardId, _ := strconv.ParseInt(i.Value, 10, 64) - dashboardByIds = append(dashboardByIds, dashboardId) - dashboardIdOrder[dashboardId] = i.Order + dashboardID, _ := strconv.ParseInt(i.Value, 10, 64) + dashboardByIDs = append(dashboardByIDs, dashboardID) + dashboardIDOrder[dashboardID] = i.Order } if i.Type == "dashboard_by_tag" { @@ -87,9 +85,9 @@ func LoadPlaylistDashboards(orgId int64, signedInUser *m.SignedInUser, playlistI result := make(dtos.PlaylistDashboardsSlice, 0) - var k, _ = populateDashboardsById(dashboardByIds, dashboardIdOrder) + var k, _ = populateDashboardsByID(dashboardByIDs, dashboardIDOrder) result = append(result, k...) - result = append(result, populateDashboardsByTag(orgId, signedInUser, dashboardByTag, dashboardTagOrder)...) + result = append(result, populateDashboardsByTag(orgID, signedInUser, dashboardByTag, dashboardTagOrder)...) sort.Sort(result) return result, nil diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 5f4ec632c4d..3e1a93abb3b 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -18,7 +18,6 @@ import ( "github.com/opentracing/opentracing-go" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" @@ -26,8 +25,8 @@ import ( ) var ( - logger log.Logger = log.New("data-proxy-log") - client *http.Client = &http.Client{ + logger = log.New("data-proxy-log") + client = &http.Client{ Timeout: time.Second * 30, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } @@ -42,22 +41,22 @@ type jwtToken struct { type DataSourceProxy struct { ds *m.DataSource - ctx *middleware.Context + ctx *m.ReqContext targetUrl *url.URL proxyPath string route *plugins.AppPluginRoute plugin *plugins.DataSourcePlugin } -func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *middleware.Context, proxyPath string) *DataSourceProxy { - targetUrl, _ := url.Parse(ds.Url) +func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy { + targetURL, _ := url.Parse(ds.Url) return &DataSourceProxy{ ds: ds, plugin: plugin, ctx: ctx, proxyPath: proxyPath, - targetUrl: targetUrl, + targetUrl: targetURL, } } @@ -90,6 +89,9 @@ func (proxy *DataSourceProxy) HandleRequest() { span.SetTag("user_id", proxy.ctx.SignedInUser.UserId) span.SetTag("org_id", proxy.ctx.SignedInUser.OrgId) + proxy.addTraceFromHeaderValue(span, "X-Panel-Id", "panel_id") + proxy.addTraceFromHeaderValue(span, "X-Dashboard-Id", "dashboard_id") + opentracing.GlobalTracer().Inject( span.Context(), opentracing.HTTPHeaders, @@ -99,6 +101,14 @@ func (proxy *DataSourceProxy) HandleRequest() { proxy.ctx.Resp.Header().Del("Set-Cookie") } +func (proxy *DataSourceProxy) addTraceFromHeaderValue(span opentracing.Span, headerName string, tagName string) { + panelId := proxy.ctx.Req.Header.Get(headerName) + dashId, err := strconv.Atoi(panelId) + if err == nil { + span.SetTag(tagName, dashId) + } +} + func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { return func(req *http.Request) { req.URL.Scheme = proxy.targetUrl.Scheme @@ -190,8 +200,14 @@ func (proxy *DataSourceProxy) validateRequest() error { } if proxy.ds.Type == m.DS_PROMETHEUS { - if proxy.ctx.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxy.proxyPath, "api/") { - return errors.New("GET is only allowed on proxied Prometheus datasource") + if proxy.ctx.Req.Request.Method == "DELETE" { + return errors.New("Deletes not allowed on proxied Prometheus datasource") + } + if proxy.ctx.Req.Request.Method == "PUT" { + return errors.New("Puts not allowed on proxied Prometheus datasource") + } + if proxy.ctx.Req.Request.Method == "POST" && !(proxy.proxyPath == "api/v1/query" || proxy.proxyPath == "api/v1/query_range") { + return errors.New("Posts not allowed on proxied Prometheus datasource except on /query and /query_range") } } @@ -255,7 +271,7 @@ func (proxy *DataSourceProxy) logRequest() { "body", body) } -func checkWhiteList(c *middleware.Context, host string) bool { +func checkWhiteList(c *m.ReqContext, host string) bool { if host != "" && len(setting.DataProxyWhiteList) > 0 { if _, exists := setting.DataProxyWhiteList[host]; !exists { c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) @@ -274,16 +290,16 @@ func (proxy *DataSourceProxy) applyRoute(req *http.Request) { SecureJsonData: proxy.ds.SecureJsonData.Decrypt(), } - routeUrl, err := url.Parse(proxy.route.Url) + routeURL, err := url.Parse(proxy.route.Url) if err != nil { logger.Error("Error parsing plugin route url") return } - req.URL.Scheme = routeUrl.Scheme - req.URL.Host = routeUrl.Host - req.Host = routeUrl.Host - req.URL.Path = util.JoinUrlFragments(routeUrl.Path, proxy.proxyPath) + req.URL.Scheme = routeURL.Scheme + req.URL.Host = routeURL.Host + req.Host = routeURL.Host + req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxy.proxyPath) if err := addHeaders(&req.Header, proxy.route, data); err != nil { logger.Error("Failed to render plugin headers", "error", err) @@ -315,11 +331,11 @@ func (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) params := make(url.Values) for key, value := range proxy.route.TokenAuth.Params { - if interpolatedParam, err := interpolateString(value, data); err != nil { + interpolatedParam, err := interpolateString(value, data) + if err != nil { return "", err - } else { - params.Add(key, interpolatedParam) } + params.Add(key, interpolatedParam) } getTokenReq, _ := http.NewRequest("POST", urlInterpolated, bytes.NewBufferString(params.Encode())) @@ -349,13 +365,13 @@ func (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) func interpolateString(text string, data templateData) (string, error) { t, err := template.New("content").Parse(text) if err != nil { - return "", errors.New(fmt.Sprintf("Could not parse template %s.", text)) + return "", fmt.Errorf("could not parse template %s", text) } var contentBuf bytes.Buffer err = t.Execute(&contentBuf, data) if err != nil { - return "", errors.New(fmt.Sprintf("Failed to execute template %s.", text)) + return "", fmt.Errorf("failed to execute template %s", text) } return contentBuf.String(), nil diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index a7a869b2a9f..3fc1392a851 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -8,7 +8,6 @@ import ( macaron "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" @@ -61,7 +60,7 @@ func TestDSRouteRule(t *testing.T) { } req, _ := http.NewRequest("GET", "http://localhost/asd", nil) - ctx := &middleware.Context{ + ctx := &m.ReqContext{ Context: &macaron.Context{ Req: macaron.Request{Request: req}, }, @@ -104,12 +103,12 @@ func TestDSRouteRule(t *testing.T) { Convey("When proxying graphite", func() { plugin := &plugins.DataSourcePlugin{} ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} - ctx := &middleware.Context{} + ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "/render") - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl} + requestURL, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestURL} proxy.getDirector()(&req) @@ -130,11 +129,11 @@ func TestDSRouteRule(t *testing.T) { Password: "password", } - ctx := &middleware.Context{} + ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "") - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl} + requestURL, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestURL} proxy.getDirector()(&req) @@ -160,11 +159,11 @@ func TestDSRouteRule(t *testing.T) { JsonData: json, } - ctx := &middleware.Context{} + ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "") - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl, Header: make(http.Header)} + requestURL, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestURL, Header: make(http.Header)} cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test" req.Header.Set("Cookie", cookies) @@ -186,11 +185,11 @@ func TestDSRouteRule(t *testing.T) { JsonData: json, } - ctx := &middleware.Context{} + ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "") - requestUrl, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestUrl, Header: make(http.Header)} + requestURL, _ := url.Parse("http://grafana.com/sub") + req := http.Request{URL: requestURL, Header: make(http.Header)} cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test" req.Header.Set("Cookie", cookies) diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 59138884228..ffbe470cb20 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/util" @@ -20,10 +19,10 @@ type templateData struct { SecureJsonData map[string]string } -func getHeaders(route *plugins.AppPluginRoute, orgId int64, appId string) (http.Header, error) { +func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.Header, error) { result := http.Header{} - query := m.GetPluginSettingByIdQuery{OrgId: orgId, PluginId: appId} + query := m.GetPluginSettingByIdQuery{OrgId: orgId, PluginId: appID} if err := bus.Dispatch(&query); err != nil { return nil, err @@ -38,16 +37,16 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appId string) (http. return result, err } -func NewApiPluginProxy(ctx *middleware.Context, proxyPath string, route *plugins.AppPluginRoute, appId string) *httputil.ReverseProxy { - targetUrl, _ := url.Parse(route.Url) +func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy { + targetURL, _ := url.Parse(route.Url) director := func(req *http.Request) { - req.URL.Scheme = targetUrl.Scheme - req.URL.Host = targetUrl.Host - req.Host = targetUrl.Host + req.URL.Scheme = targetURL.Scheme + req.URL.Host = targetURL.Host + req.Host = targetURL.Host - req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath) + req.URL.Path = util.JoinUrlFragments(targetURL.Path, proxyPath) // clear cookie headers req.Header.Del("Cookie") @@ -81,7 +80,7 @@ func NewApiPluginProxy(ctx *middleware.Context, proxyPath string, route *plugins req.Header.Add("X-Grafana-Context", string(ctxJson)) if len(route.Headers) > 0 { - headers, err := getHeaders(route, ctx.OrgId, appId) + headers, err := getHeaders(route, ctx.OrgId, appID) if err != nil { ctx.JsonApiErr(500, "Could not generate plugin route header", err) return diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 0483b624a30..f757f2b9adc 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -5,13 +5,12 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" ) -func GetPluginList(c *middleware.Context) Response { +func GetPluginList(c *m.ReqContext) Response { typeFilter := c.Query("type") enabledFilter := c.Query("enabled") embeddedFilter := c.Query("embedded") @@ -20,7 +19,7 @@ func GetPluginList(c *middleware.Context) Response { pluginSettingsMap, err := plugins.GetPluginSettings(c.OrgId) if err != nil { - return ApiError(500, "Failed to get list of plugins", err) + return Error(500, "Failed to get list of plugins", err) } result := make(dtos.PluginList, 0) @@ -76,99 +75,101 @@ func GetPluginList(c *middleware.Context) Response { } sort.Sort(result) - return Json(200, result) + return JSON(200, result) } -func GetPluginSettingById(c *middleware.Context) Response { - pluginId := c.Params(":pluginId") +func GetPluginSettingByID(c *m.ReqContext) Response { + pluginID := c.Params(":pluginId") - if def, exists := plugins.Plugins[pluginId]; !exists { - return ApiError(404, "Plugin not found, no installed plugin with that id", nil) - } else { - - dto := &dtos.PluginSetting{ - Type: def.Type, - Id: def.Id, - Name: def.Name, - Info: &def.Info, - Dependencies: &def.Dependencies, - Includes: def.Includes, - BaseUrl: def.BaseUrl, - Module: def.Module, - DefaultNavUrl: def.DefaultNavUrl, - LatestVersion: def.GrafanaNetVersion, - HasUpdate: def.GrafanaNetHasUpdate, - State: def.State, - } - - query := m.GetPluginSettingByIdQuery{PluginId: pluginId, OrgId: c.OrgId} - if err := bus.Dispatch(&query); err != nil { - if err != m.ErrPluginSettingNotFound { - return ApiError(500, "Failed to get login settings", nil) - } - } else { - dto.Enabled = query.Result.Enabled - dto.Pinned = query.Result.Pinned - dto.JsonData = query.Result.JsonData - } - - return Json(200, dto) + def, exists := plugins.Plugins[pluginID] + if !exists { + return Error(404, "Plugin not found, no installed plugin with that id", nil) } + + dto := &dtos.PluginSetting{ + Type: def.Type, + Id: def.Id, + Name: def.Name, + Info: &def.Info, + Dependencies: &def.Dependencies, + Includes: def.Includes, + BaseUrl: def.BaseUrl, + Module: def.Module, + DefaultNavUrl: def.DefaultNavUrl, + LatestVersion: def.GrafanaNetVersion, + HasUpdate: def.GrafanaNetHasUpdate, + State: def.State, + } + + query := m.GetPluginSettingByIdQuery{PluginId: pluginID, OrgId: c.OrgId} + if err := bus.Dispatch(&query); err != nil { + if err != m.ErrPluginSettingNotFound { + return Error(500, "Failed to get login settings", nil) + } + } else { + dto.Enabled = query.Result.Enabled + dto.Pinned = query.Result.Pinned + dto.JsonData = query.Result.JsonData + } + + return JSON(200, dto) } -func UpdatePluginSetting(c *middleware.Context, cmd m.UpdatePluginSettingCmd) Response { - pluginId := c.Params(":pluginId") +func UpdatePluginSetting(c *m.ReqContext, cmd m.UpdatePluginSettingCmd) Response { + pluginID := c.Params(":pluginId") cmd.OrgId = c.OrgId - cmd.PluginId = pluginId + cmd.PluginId = pluginID if _, ok := plugins.Apps[cmd.PluginId]; !ok { - return ApiError(404, "Plugin not installed.", nil) + return Error(404, "Plugin not installed.", nil) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update plugin setting", err) + return Error(500, "Failed to update plugin setting", err) } - return ApiSuccess("Plugin settings updated") + return Success("Plugin settings updated") } -func GetPluginDashboards(c *middleware.Context) Response { - pluginId := c.Params(":pluginId") +func GetPluginDashboards(c *m.ReqContext) Response { + pluginID := c.Params(":pluginId") - if list, err := plugins.GetPluginDashboards(c.OrgId, pluginId); err != nil { + list, err := plugins.GetPluginDashboards(c.OrgId, pluginID) + if err != nil { if notfound, ok := err.(plugins.PluginNotFoundError); ok { - return ApiError(404, notfound.Error(), nil) + return Error(404, notfound.Error(), nil) } - return ApiError(500, "Failed to get plugin dashboards", err) - } else { - return Json(200, list) + return Error(500, "Failed to get plugin dashboards", err) } + + return JSON(200, list) } -func GetPluginMarkdown(c *middleware.Context) Response { - pluginId := c.Params(":pluginId") +func GetPluginMarkdown(c *m.ReqContext) Response { + pluginID := c.Params(":pluginId") name := c.Params(":name") - if content, err := plugins.GetPluginMarkdown(pluginId, name); err != nil { + content, err := plugins.GetPluginMarkdown(pluginID, name) + if err != nil { if notfound, ok := err.(plugins.PluginNotFoundError); ok { - return ApiError(404, notfound.Error(), nil) + return Error(404, notfound.Error(), nil) } - return ApiError(500, "Could not get markdown file", err) - } else { - resp := Respond(200, content) - resp.Header("Content-Type", "text/plain; charset=utf-8") - return resp + return Error(500, "Could not get markdown file", err) } + + resp := Respond(200, content) + resp.Header("Content-Type", "text/plain; charset=utf-8") + return resp } -func ImportDashboard(c *middleware.Context, apiCmd dtos.ImportDashboardCommand) Response { +func ImportDashboard(c *m.ReqContext, apiCmd dtos.ImportDashboardCommand) Response { cmd := plugins.ImportDashboardCommand{ OrgId: c.OrgId, - UserId: c.UserId, + User: c.SignedInUser, PluginId: apiCmd.PluginId, Path: apiCmd.Path, Inputs: apiCmd.Inputs, @@ -177,8 +178,8 @@ func ImportDashboard(c *middleware.Context, apiCmd dtos.ImportDashboardCommand) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to import dashboard", err) + return Error(500, "Failed to import dashboard", err) } - return Json(200, cmd.Result) + return JSON(200, cmd.Result) } diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 795b8994470..26695130975 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -3,71 +3,70 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) // POST /api/preferences/set-home-dash -func SetHomeDashboard(c *middleware.Context, cmd m.SavePreferencesCommand) Response { +func SetHomeDashboard(c *m.ReqContext, cmd m.SavePreferencesCommand) Response { cmd.UserId = c.UserId cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to set home dashboard", err) + return Error(500, "Failed to set home dashboard", err) } - return ApiSuccess("Home dashboard set") + return Success("Home dashboard set") } // GET /api/user/preferences -func GetUserPreferences(c *middleware.Context) Response { +func GetUserPreferences(c *m.ReqContext) Response { return getPreferencesFor(c.OrgId, c.UserId) } -func getPreferencesFor(orgId int64, userId int64) Response { - prefsQuery := m.GetPreferencesQuery{UserId: userId, OrgId: orgId} +func getPreferencesFor(orgID int64, userID int64) Response { + prefsQuery := m.GetPreferencesQuery{UserId: userID, OrgId: orgID} if err := bus.Dispatch(&prefsQuery); err != nil { - return ApiError(500, "Failed to get preferences", err) + return Error(500, "Failed to get preferences", err) } dto := dtos.Prefs{ Theme: prefsQuery.Result.Theme, - HomeDashboardId: prefsQuery.Result.HomeDashboardId, + HomeDashboardID: prefsQuery.Result.HomeDashboardId, Timezone: prefsQuery.Result.Timezone, } - return Json(200, &dto) + return JSON(200, &dto) } // PUT /api/user/preferences -func UpdateUserPreferences(c *middleware.Context, dtoCmd dtos.UpdatePrefsCmd) Response { +func UpdateUserPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { return updatePreferencesFor(c.OrgId, c.UserId, &dtoCmd) } -func updatePreferencesFor(orgId int64, userId int64, dtoCmd *dtos.UpdatePrefsCmd) Response { +func updatePreferencesFor(orgID int64, userID int64, dtoCmd *dtos.UpdatePrefsCmd) Response { saveCmd := m.SavePreferencesCommand{ - UserId: userId, - OrgId: orgId, + UserId: userID, + OrgId: orgID, Theme: dtoCmd.Theme, Timezone: dtoCmd.Timezone, - HomeDashboardId: dtoCmd.HomeDashboardId, + HomeDashboardId: dtoCmd.HomeDashboardID, } if err := bus.Dispatch(&saveCmd); err != nil { - return ApiError(500, "Failed to save preferences", err) + return Error(500, "Failed to save preferences", err) } - return ApiSuccess("Preferences updated") + return Success("Preferences updated") } // GET /api/org/preferences -func GetOrgPreferences(c *middleware.Context) Response { +func GetOrgPreferences(c *m.ReqContext) Response { return getPreferencesFor(c.OrgId, 0) } // PUT /api/org/preferences -func UpdateOrgPreferences(c *middleware.Context, dtoCmd dtos.UpdatePrefsCmd) Response { +func UpdateOrgPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { return updatePreferencesFor(c.OrgId, 0, &dtoCmd) } diff --git a/pkg/api/quota.go b/pkg/api/quota.go index d8585435430..d469f843930 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -2,67 +2,66 @@ package api import ( "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -func GetOrgQuotas(c *middleware.Context) Response { +func GetOrgQuotas(c *m.ReqContext) Response { if !setting.Quota.Enabled { - return ApiError(404, "Quotas not enabled", nil) + return Error(404, "Quotas not enabled", nil) } query := m.GetOrgQuotasQuery{OrgId: c.ParamsInt64(":orgId")} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to get org quotas", err) + return Error(500, "Failed to get org quotas", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func UpdateOrgQuota(c *middleware.Context, cmd m.UpdateOrgQuotaCmd) Response { +func UpdateOrgQuota(c *m.ReqContext, cmd m.UpdateOrgQuotaCmd) Response { if !setting.Quota.Enabled { - return ApiError(404, "Quotas not enabled", nil) + return Error(404, "Quotas not enabled", nil) } cmd.OrgId = c.ParamsInt64(":orgId") cmd.Target = c.Params(":target") if _, ok := setting.Quota.Org.ToMap()[cmd.Target]; !ok { - return ApiError(404, "Invalid quota target", nil) + return Error(404, "Invalid quota target", nil) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update org quotas", err) + return Error(500, "Failed to update org quotas", err) } - return ApiSuccess("Organization quota updated") + return Success("Organization quota updated") } -func GetUserQuotas(c *middleware.Context) Response { +func GetUserQuotas(c *m.ReqContext) Response { if !setting.Quota.Enabled { - return ApiError(404, "Quotas not enabled", nil) + return Error(404, "Quotas not enabled", nil) } query := m.GetUserQuotasQuery{UserId: c.ParamsInt64(":id")} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to get org quotas", err) + return Error(500, "Failed to get org quotas", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func UpdateUserQuota(c *middleware.Context, cmd m.UpdateUserQuotaCmd) Response { +func UpdateUserQuota(c *m.ReqContext, cmd m.UpdateUserQuotaCmd) Response { if !setting.Quota.Enabled { - return ApiError(404, "Quotas not enabled", nil) + return Error(404, "Quotas not enabled", nil) } cmd.UserId = c.ParamsInt64(":id") cmd.Target = c.Params(":target") if _, ok := setting.Quota.User.ToMap()[cmd.Target]; !ok { - return ApiError(404, "Invalid quota target", nil) + return Error(404, "Invalid quota target", nil) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update org quotas", err) + return Error(500, "Failed to update org quotas", err) } - return ApiSuccess("Organization quota updated") + return Success("Organization quota updated") } diff --git a/pkg/api/render.go b/pkg/api/render.go index 65733cfab15..6e948ed294c 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -5,11 +5,11 @@ import ( "net/http" "github.com/grafana/grafana/pkg/components/renderer" - "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) -func RenderToPng(c *middleware.Context) { +func RenderToPng(c *m.ReqContext) { queryReader, err := util.NewUrlQueryReader(c.Req.URL) if err != nil { c.Handle(400, "Render parameters error", err) diff --git a/pkg/api/search.go b/pkg/api/search.go index f79385d83f8..8c2b708d5a2 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -5,40 +5,39 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" ) -func Search(c *middleware.Context) { +func Search(c *m.ReqContext) { query := c.Query("query") tags := c.QueryStrings("tag") starred := c.Query("starred") limit := c.QueryInt("limit") dashboardType := c.Query("type") - permission := models.PERMISSION_VIEW + permission := m.PERMISSION_VIEW if limit == 0 { limit = 1000 } if c.Query("permission") == "Edit" { - permission = models.PERMISSION_EDIT + permission = m.PERMISSION_EDIT } - dbids := make([]int64, 0) + dbIDs := make([]int64, 0) for _, id := range c.QueryStrings("dashboardIds") { - dashboardId, err := strconv.ParseInt(id, 10, 64) + dashboardID, err := strconv.ParseInt(id, 10, 64) if err == nil { - dbids = append(dbids, dashboardId) + dbIDs = append(dbIDs, dashboardID) } } - folderIds := make([]int64, 0) + folderIDs := make([]int64, 0) for _, id := range c.QueryStrings("folderIds") { - folderId, err := strconv.ParseInt(id, 10, 64) + folderID, err := strconv.ParseInt(id, 10, 64) if err == nil { - folderIds = append(folderIds, folderId) + folderIDs = append(folderIDs, folderID) } } @@ -49,9 +48,9 @@ func Search(c *middleware.Context) { Limit: limit, IsStarred: starred == "true", OrgId: c.OrgId, - DashboardIds: dbids, + DashboardIds: dbIDs, Type: dashboardType, - FolderIds: folderIds, + FolderIds: folderIDs, Permission: permission, } diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 36ece023087..200a3ebc9d1 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -5,29 +5,28 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) // GET /api/user/signup/options -func GetSignUpOptions(c *middleware.Context) Response { - return Json(200, util.DynMap{ +func GetSignUpOptions(c *m.ReqContext) Response { + return JSON(200, util.DynMap{ "verifyEmailEnabled": setting.VerifyEmailEnabled, "autoAssignOrg": setting.AutoAssignOrg, }) } // POST /api/user/signup -func SignUp(c *middleware.Context, form dtos.SignUpForm) Response { +func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response { if !setting.AllowUserSignUp { - return ApiError(401, "User signup is disabled", nil) + return Error(401, "User signup is disabled", nil) } existing := m.GetUserByLoginQuery{LoginOrEmail: form.Email} if err := bus.Dispatch(&existing); err == nil { - return ApiError(422, "User with same email address already exists", nil) + return Error(422, "User with same email address already exists", nil) } cmd := m.CreateTempUserCommand{} @@ -39,7 +38,7 @@ func SignUp(c *middleware.Context, form dtos.SignUpForm) Response { cmd.RemoteAddr = c.Req.RemoteAddr if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to create signup", err) + return Error(500, "Failed to create signup", err) } bus.Publish(&events.SignUpStarted{ @@ -49,12 +48,12 @@ func SignUp(c *middleware.Context, form dtos.SignUpForm) Response { metrics.M_Api_User_SignUpStarted.Inc() - return Json(200, util.DynMap{"status": "SignUpCreated"}) + return JSON(200, util.DynMap{"status": "SignUpCreated"}) } -func SignUpStep2(c *middleware.Context, form dtos.SignUpStep2Form) Response { +func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { if !setting.AllowUserSignUp { - return ApiError(401, "User signup is disabled", nil) + return Error(401, "User signup is disabled", nil) } createUserCmd := m.CreateUserCommand{ @@ -76,12 +75,12 @@ func SignUpStep2(c *middleware.Context, form dtos.SignUpStep2Form) Response { // check if user exists existing := m.GetUserByLoginQuery{LoginOrEmail: form.Email} if err := bus.Dispatch(&existing); err == nil { - return ApiError(401, "User with same email address already exists", nil) + return Error(401, "User with same email address already exists", nil) } // dispatch create command if err := bus.Dispatch(&createUserCmd); err != nil { - return ApiError(500, "Failed to create user", err) + return Error(500, "Failed to create user", err) } // publish signup event @@ -99,7 +98,7 @@ func SignUpStep2(c *middleware.Context, form dtos.SignUpStep2Form) Response { // check for pending invites invitesQuery := m.GetTempUsersQuery{Email: form.Email, Status: m.TmpUserInvitePending} if err := bus.Dispatch(&invitesQuery); err != nil { - return ApiError(500, "Failed to query database for invites", err) + return Error(500, "Failed to query database for invites", err) } apiResponse := util.DynMap{"message": "User sign up completed successfully", "code": "redirect-to-landing-page"} @@ -113,7 +112,7 @@ func SignUpStep2(c *middleware.Context, form dtos.SignUpStep2Form) Response { loginUserWithUser(user, c) metrics.M_Api_User_SignUpCompleted.Inc() - return Json(200, apiResponse) + return JSON(200, apiResponse) } func verifyUserSignUpEmail(email string, code string) (bool, Response) { @@ -121,14 +120,14 @@ func verifyUserSignUpEmail(email string, code string) (bool, Response) { if err := bus.Dispatch(&query); err != nil { if err == m.ErrTempUserNotFound { - return false, ApiError(404, "Invalid email verification code", nil) + return false, Error(404, "Invalid email verification code", nil) } - return false, ApiError(500, "Failed to read temp user", err) + return false, Error(500, "Failed to read temp user", err) } tempUser := query.Result if tempUser.Email != email { - return false, ApiError(404, "Email verification code does not match email", nil) + return false, Error(404, "Email verification code does not match email", nil) } return true, nil diff --git a/pkg/api/stars.go b/pkg/api/stars.go index c6f9d037eba..2c55b95dfbe 100644 --- a/pkg/api/stars.go +++ b/pkg/api/stars.go @@ -2,39 +2,38 @@ package api import ( "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" ) -func StarDashboard(c *middleware.Context) Response { +func StarDashboard(c *m.ReqContext) Response { if !c.IsSignedIn { - return ApiError(412, "You need to sign in to star dashboards", nil) + return Error(412, "You need to sign in to star dashboards", nil) } cmd := m.StarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} if cmd.DashboardId <= 0 { - return ApiError(400, "Missing dashboard id", nil) + return Error(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to star dashboard", err) + return Error(500, "Failed to star dashboard", err) } - return ApiSuccess("Dashboard starred!") + return Success("Dashboard starred!") } -func UnstarDashboard(c *middleware.Context) Response { +func UnstarDashboard(c *m.ReqContext) Response { cmd := m.UnstarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} if cmd.DashboardId <= 0 { - return ApiError(400, "Missing dashboard id", nil) + return Error(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to unstar dashboard", err) + return Error(500, "Failed to unstar dashboard", err) } - return ApiSuccess("Dashboard unstarred") + return Success("Dashboard unstarred") } diff --git a/pkg/api/team.go b/pkg/api/team.go index f11eca68b91..9919305881b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -3,54 +3,53 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) // POST /api/teams -func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response { +func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { - return ApiError(409, "Team name taken", err) + return Error(409, "Team name taken", err) } - return ApiError(500, "Failed to create Team", err) + return Error(500, "Failed to create Team", err) } - return Json(200, &util.DynMap{ + return JSON(200, &util.DynMap{ "teamId": cmd.Result.Id, "message": "Team created", }) } // PUT /api/teams/:teamId -func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response { +func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { - return ApiError(400, "Team name taken", err) + return Error(400, "Team name taken", err) } - return ApiError(500, "Failed to update Team", err) + return Error(500, "Failed to update Team", err) } - return ApiSuccess("Team updated") + return Success("Team updated") } // DELETE /api/teams/:teamId -func DeleteTeamById(c *middleware.Context) Response { +func DeleteTeamByID(c *m.ReqContext) Response { if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil { if err == m.ErrTeamNotFound { - return ApiError(404, "Failed to delete Team. ID not found", nil) + return Error(404, "Failed to delete Team. ID not found", nil) } - return ApiError(500, "Failed to update Team", err) + return Error(500, "Failed to update Team", err) } - return ApiSuccess("Team deleted") + return Success("Team deleted") } // GET /api/teams/search -func SearchTeams(c *middleware.Context) Response { +func SearchTeams(c *m.ReqContext) Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -69,7 +68,7 @@ func SearchTeams(c *middleware.Context) Response { } if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to search Teams", err) + return Error(500, "Failed to search Teams", err) } for _, team := range query.Result.Teams { @@ -79,20 +78,20 @@ func SearchTeams(c *middleware.Context) Response { query.Result.Page = page query.Result.PerPage = perPage - return Json(200, query.Result) + return JSON(200, query.Result) } // GET /api/teams/:teamId -func GetTeamById(c *middleware.Context) Response { +func GetTeamByID(c *m.ReqContext) Response { query := m.GetTeamByIdQuery{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrTeamNotFound { - return ApiError(404, "Team not found", err) + return Error(404, "Team not found", err) } - return ApiError(500, "Failed to get Team", err) + return Error(500, "Failed to get Team", err) } - return Json(200, &query.Result) + return JSON(200, &query.Result) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 59dfc20b791..60a170a8c31 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -3,47 +3,59 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) // GET /api/teams/:teamId/members -func GetTeamMembers(c *middleware.Context) Response { +func GetTeamMembers(c *m.ReqContext) Response { query := m.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId")} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to get Team Members", err) + return Error(500, "Failed to get Team Members", err) } for _, member := range query.Result { member.AvatarUrl = dtos.GetGravatarUrl(member.Email) } - return Json(200, query.Result) + return JSON(200, query.Result) } // POST /api/teams/:teamId/members -func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response { +func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { cmd.TeamId = c.ParamsInt64(":teamId") cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - if err == m.ErrTeamMemberAlreadyAdded { - return ApiError(400, "User is already added to this team", err) + if err == m.ErrTeamNotFound { + return Error(404, "Team not found", nil) } - return ApiError(500, "Failed to add Member to Team", err) + + if err == m.ErrTeamMemberAlreadyAdded { + return Error(400, "User is already added to this team", nil) + } + + return Error(500, "Failed to add Member to Team", err) } - return Json(200, &util.DynMap{ + return JSON(200, &util.DynMap{ "message": "Member added to Team", }) } // DELETE /api/teams/:teamId/members/:userId -func RemoveTeamMember(c *middleware.Context) Response { +func RemoveTeamMember(c *m.ReqContext) Response { if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { - return ApiError(500, "Failed to remove Member from Team", err) + if err == m.ErrTeamNotFound { + return Error(404, "Team not found", nil) + } + + if err == m.ErrTeamMemberNotFound { + return Error(404, "Team member not found", nil) + } + + return Error(500, "Failed to remove Member from Team", err) } - return ApiSuccess("Team Member removed") + return Success("Team Member removed") } diff --git a/pkg/api/user.go b/pkg/api/user.go index 9a041d30272..725c623575f 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -3,43 +3,42 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) // GET /api/user (current authenticated user) -func GetSignedInUser(c *middleware.Context) Response { +func GetSignedInUser(c *m.ReqContext) Response { return getUserUserProfile(c.UserId) } // GET /api/users/:id -func GetUserById(c *middleware.Context) Response { +func GetUserByID(c *m.ReqContext) Response { return getUserUserProfile(c.ParamsInt64(":id")) } -func getUserUserProfile(userId int64) Response { - query := m.GetUserProfileQuery{UserId: userId} +func getUserUserProfile(userID int64) Response { + query := m.GetUserProfileQuery{UserId: userID} if err := bus.Dispatch(&query); err != nil { if err == m.ErrUserNotFound { - return ApiError(404, m.ErrUserNotFound.Error(), nil) + return Error(404, m.ErrUserNotFound.Error(), nil) } - return ApiError(500, "Failed to get user", err) + return Error(500, "Failed to get user", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } // GET /api/users/lookup -func GetUserByLoginOrEmail(c *middleware.Context) Response { +func GetUserByLoginOrEmail(c *m.ReqContext) Response { query := m.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")} if err := bus.Dispatch(&query); err != nil { if err == m.ErrUserNotFound { - return ApiError(404, m.ErrUserNotFound.Error(), nil) + return Error(404, m.ErrUserNotFound.Error(), nil) } - return ApiError(500, "Failed to get user", err) + return Error(500, "Failed to get user", err) } user := query.Result result := m.UserProfileDTO{ @@ -51,17 +50,17 @@ func GetUserByLoginOrEmail(c *middleware.Context) Response { IsGrafanaAdmin: user.IsAdmin, OrgId: user.OrgId, } - return Json(200, &result) + return JSON(200, &result) } // POST /api/user -func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { +func UpdateSignedInUser(c *m.ReqContext, cmd m.UpdateUserCommand) Response { if setting.AuthProxyEnabled { if setting.AuthProxyHeaderProperty == "email" && cmd.Email != c.Email { - return ApiError(400, "Not allowed to change email when auth proxy is using email property", nil) + return Error(400, "Not allowed to change email when auth proxy is using email property", nil) } if setting.AuthProxyHeaderProperty == "username" && cmd.Login != c.Login { - return ApiError(400, "Not allowed to change username when auth proxy is using username property", nil) + return Error(400, "Not allowed to change username when auth proxy is using username property", nil) } } cmd.UserId = c.UserId @@ -69,66 +68,66 @@ func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response } // POST /api/users/:id -func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { +func UpdateUser(c *m.ReqContext, cmd m.UpdateUserCommand) Response { cmd.UserId = c.ParamsInt64(":id") return handleUpdateUser(cmd) } //POST /api/users/:id/using/:orgId -func UpdateUserActiveOrg(c *middleware.Context) Response { - userId := c.ParamsInt64(":id") - orgId := c.ParamsInt64(":orgId") +func UpdateUserActiveOrg(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + orgID := c.ParamsInt64(":orgId") - if !validateUsingOrg(userId, orgId) { - return ApiError(401, "Not a valid organization", nil) + if !validateUsingOrg(userID, orgID) { + return Error(401, "Not a valid organization", nil) } - cmd := m.SetUsingOrgCommand{UserId: userId, OrgId: orgId} + cmd := m.SetUsingOrgCommand{UserId: userID, OrgId: orgID} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to change active organization", err) + return Error(500, "Failed to change active organization", err) } - return ApiSuccess("Active organization changed") + return Success("Active organization changed") } func handleUpdateUser(cmd m.UpdateUserCommand) Response { if len(cmd.Login) == 0 { cmd.Login = cmd.Email if len(cmd.Login) == 0 { - return ApiError(400, "Validation error, need to specify either username or email", nil) + return Error(400, "Validation error, need to specify either username or email", nil) } } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update user", err) + return Error(500, "Failed to update user", err) } - return ApiSuccess("User updated") + return Success("User updated") } // GET /api/user/orgs -func GetSignedInUserOrgList(c *middleware.Context) Response { +func GetSignedInUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.UserId) } // GET /api/user/:id/orgs -func GetUserOrgList(c *middleware.Context) Response { +func GetUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.ParamsInt64(":id")) } -func getUserOrgList(userId int64) Response { - query := m.GetUserOrgListQuery{UserId: userId} +func getUserOrgList(userID int64) Response { + query := m.GetUserOrgListQuery{UserId: userID} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Failed to get user organizations", err) + return Error(500, "Failed to get user organizations", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func validateUsingOrg(userId int64, orgId int64) bool { - query := m.GetUserOrgListQuery{UserId: userId} +func validateUsingOrg(userID int64, orgID int64) bool { + query := m.GetUserOrgListQuery{UserId: userID} if err := bus.Dispatch(&query); err != nil { return false @@ -137,7 +136,7 @@ func validateUsingOrg(userId int64, orgId int64) bool { // validate that the org id in the list valid := false for _, other := range query.Result { - if other.OrgId == orgId { + if other.OrgId == orgID { valid = true } } @@ -146,31 +145,31 @@ func validateUsingOrg(userId int64, orgId int64) bool { } // POST /api/user/using/:id -func UserSetUsingOrg(c *middleware.Context) Response { - orgId := c.ParamsInt64(":id") +func UserSetUsingOrg(c *m.ReqContext) Response { + orgID := c.ParamsInt64(":id") - if !validateUsingOrg(c.UserId, orgId) { - return ApiError(401, "Not a valid organization", nil) + if !validateUsingOrg(c.UserId, orgID) { + return Error(401, "Not a valid organization", nil) } - cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId} + cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to change active organization", err) + return Error(500, "Failed to change active organization", err) } - return ApiSuccess("Active organization changed") + return Success("Active organization changed") } // GET /profile/switch-org/:id -func ChangeActiveOrgAndRedirectToHome(c *middleware.Context) { - orgId := c.ParamsInt64(":id") +func ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) { + orgID := c.ParamsInt64(":id") - if !validateUsingOrg(c.UserId, orgId) { + if !validateUsingOrg(c.UserId, orgID) { NotFoundHandler(c) } - cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId} + cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID} if err := bus.Dispatch(&cmd); err != nil { NotFoundHandler(c) @@ -179,58 +178,58 @@ func ChangeActiveOrgAndRedirectToHome(c *middleware.Context) { c.Redirect(setting.AppSubUrl + "/") } -func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) Response { +func ChangeUserPassword(c *m.ReqContext, cmd m.ChangeUserPasswordCommand) Response { if setting.LdapEnabled || setting.AuthProxyEnabled { - return ApiError(400, "Not allowed to change password when LDAP or Auth Proxy is enabled", nil) + return Error(400, "Not allowed to change password when LDAP or Auth Proxy is enabled", nil) } userQuery := m.GetUserByIdQuery{Id: c.UserId} if err := bus.Dispatch(&userQuery); err != nil { - return ApiError(500, "Could not read user from database", err) + return Error(500, "Could not read user from database", err) } passwordHashed := util.EncodePassword(cmd.OldPassword, userQuery.Result.Salt) if passwordHashed != userQuery.Result.Password { - return ApiError(401, "Invalid old password", nil) + return Error(401, "Invalid old password", nil) } password := m.Password(cmd.NewPassword) if password.IsWeak() { - return ApiError(400, "New password is too short", nil) + return Error(400, "New password is too short", nil) } cmd.UserId = c.UserId cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt) if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to change user password", err) + return Error(500, "Failed to change user password", err) } - return ApiSuccess("User password changed") + return Success("User password changed") } // GET /api/users -func SearchUsers(c *middleware.Context) Response { +func SearchUsers(c *m.ReqContext) Response { query, err := searchUser(c) if err != nil { - return ApiError(500, "Failed to fetch users", err) + return Error(500, "Failed to fetch users", err) } - return Json(200, query.Result.Users) + return JSON(200, query.Result.Users) } // GET /api/users/search -func SearchUsersWithPaging(c *middleware.Context) Response { +func SearchUsersWithPaging(c *m.ReqContext) Response { query, err := searchUser(c) if err != nil { - return ApiError(500, "Failed to fetch users", err) + return Error(500, "Failed to fetch users", err) } - return Json(200, query.Result) + return JSON(200, query.Result) } -func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) { +func searchUser(c *m.ReqContext) (*m.SearchUsersQuery, error) { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -258,7 +257,7 @@ func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) { return query, nil } -func SetHelpFlag(c *middleware.Context) Response { +func SetHelpFlag(c *m.ReqContext) Response { flag := c.ParamsInt64(":id") bitmask := &c.HelpFlags1 @@ -270,21 +269,21 @@ func SetHelpFlag(c *middleware.Context) Response { } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update help flag", err) + return Error(500, "Failed to update help flag", err) } - return Json(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) + return JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } -func ClearHelpFlags(c *middleware.Context) Response { +func ClearHelpFlags(c *m.ReqContext) Response { cmd := m.SetUserHelpFlagCommand{ UserId: c.UserId, HelpFlags1: m.HelpFlags1(0), } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to update help flag", err) + return Error(500, "Failed to update help flag", err) } - return Json(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) + return JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index a1b249d9c81..f40bc9c081b 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -94,7 +94,7 @@ func InstallPlugin(pluginName, version string, c CommandLine) error { res, _ := s.ReadPlugin(pluginFolder, pluginName) for _, v := range res.Dependencies.Plugins { - InstallPlugin(v.Id, version, c) + InstallPlugin(v.Id, "", c) logger.Infof("Installed dependency: %v ✔\n", v.Id) } diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 8ed3196e4ad..5bbf43087ec 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -49,7 +49,7 @@ type GrafanaServerImpl struct { childRoutines *errgroup.Group log log.Logger - httpServer *api.HttpServer + httpServer *api.HTTPServer } func (g *GrafanaServerImpl) Start() error { @@ -120,7 +120,7 @@ func (g *GrafanaServerImpl) initLogging() { } func (g *GrafanaServerImpl) startHttpServer() error { - g.httpServer = api.NewHttpServer() + g.httpServer = api.NewHTTPServer() err := g.httpServer.Start(g.context) diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index 313f7892707..26751ddd5c7 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -72,7 +72,9 @@ func RenderToPng(params *RenderOpts) (string, error) { localDomain = setting.HttpAddr } - url := fmt.Sprintf("%s://%s:%s/%s", setting.Protocol, localDomain, setting.HttpPort, params.Path) + // &render=1 signals to the legacy redirect layer to + // avoid redirect these requests. + url := fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, localDomain, setting.HttpPort, params.Path) binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable)) scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js")) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 826287e12f3..37e79c01071 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -7,6 +7,7 @@ import ( "gopkg.in/macaron.v1" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" ) @@ -15,17 +16,17 @@ type AuthOptions struct { ReqSignedIn bool } -func getRequestUserId(c *Context) int64 { - userId := c.Session.Get(SESS_KEY_USERID) +func getRequestUserId(c *m.ReqContext) int64 { + userID := c.Session.Get(session.SESS_KEY_USERID) - if userId != nil { - return userId.(int64) + if userID != nil { + return userID.(int64) } return 0 } -func getApiKey(c *Context) string { +func getApiKey(c *m.ReqContext) string { header := c.Req.Header.Get("Authorization") parts := strings.SplitN(header, " ", 2) if len(parts) == 2 && parts[0] == "Bearer" { @@ -36,7 +37,7 @@ func getApiKey(c *Context) string { return "" } -func accessForbidden(c *Context) { +func accessForbidden(c *m.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(403, "Permission denied", nil) return @@ -45,18 +46,19 @@ func accessForbidden(c *Context) { c.Redirect(setting.AppSubUrl + "/") } -func notAuthorized(c *Context) { +func notAuthorized(c *m.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(401, "Unauthorized", nil) return } - c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/") + c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/", nil, false, true) + c.Redirect(setting.AppSubUrl + "/login") } func RoleAuth(roles ...m.RoleType) macaron.Handler { - return func(c *Context) { + return func(c *m.ReqContext) { ok := false for _, role := range roles { if role == c.OrgRole { @@ -71,7 +73,7 @@ func RoleAuth(roles ...m.RoleType) macaron.Handler { } func Auth(options *AuthOptions) macaron.Handler { - return func(c *Context) { + return func(c *m.ReqContext) { if !c.IsSignedIn && options.ReqSignedIn && !c.AllowAnonymous { notAuthorized(c) return diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index 3247805ec09..adf0b7b53d5 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -1,8 +1,8 @@ package middleware import ( - "errors" "fmt" + "net" "strings" "time" @@ -10,10 +10,11 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" ) -func initContextWithAuthProxy(ctx *Context, orgId int64) bool { +func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { if !setting.AuthProxyEnabled { return false } @@ -24,41 +25,41 @@ func initContextWithAuthProxy(ctx *Context, orgId int64) bool { } // if auth proxy ip(s) defined, check if request comes from one of those - if err := checkAuthenticationProxy(ctx, proxyHeaderValue); err != nil { + if err := checkAuthenticationProxy(ctx.Req.RemoteAddr, proxyHeaderValue); err != nil { ctx.Handle(407, "Proxy authentication required", err) return true } query := getSignedInUserQueryForProxyAuth(proxyHeaderValue) - query.OrgId = orgId + query.OrgId = orgID if err := bus.Dispatch(query); err != nil { if err != m.ErrUserNotFound { ctx.Handle(500, "Failed to find user specified in auth proxy header", err) return true } - if setting.AuthProxyAutoSignUp { - cmd := getCreateUserCommandForProxyAuth(proxyHeaderValue) - if setting.LdapEnabled { - cmd.SkipOrgSetup = true - } - - if err := bus.Dispatch(cmd); err != nil { - ctx.Handle(500, "Failed to create user specified in auth proxy header", err) - return true - } - query = &m.GetSignedInUserQuery{UserId: cmd.Result.Id, OrgId: orgId} - if err := bus.Dispatch(query); err != nil { - ctx.Handle(500, "Failed find user after creation", err) - return true - } - } else { + if !setting.AuthProxyAutoSignUp { return false } + + cmd := getCreateUserCommandForProxyAuth(proxyHeaderValue) + if setting.LdapEnabled { + cmd.SkipOrgSetup = true + } + + if err := bus.Dispatch(cmd); err != nil { + ctx.Handle(500, "Failed to create user specified in auth proxy header", err) + return true + } + query = &m.GetSignedInUserQuery{UserId: cmd.Result.Id, OrgId: orgID} + if err := bus.Dispatch(query); err != nil { + ctx.Handle(500, "Failed find user after creation", err) + return true + } } // initialize session - if err := ctx.Session.Start(ctx); err != nil { + if err := ctx.Session.Start(ctx.Context); err != nil { log.Error(3, "Failed to start session", err) return false } @@ -66,12 +67,12 @@ func initContextWithAuthProxy(ctx *Context, orgId int64) bool { // Make sure that we cannot share a session between different users! if getRequestUserId(ctx) > 0 && getRequestUserId(ctx) != query.Result.UserId { // remove session - if err := ctx.Session.Destory(ctx); err != nil { + if err := ctx.Session.Destory(ctx.Context); err != nil { log.Error(3, "Failed to destroy session, err") } // initialize a new session - if err := ctx.Session.Start(ctx); err != nil { + if err := ctx.Session.Start(ctx.Context); err != nil { log.Error(3, "Failed to start session", err) } } @@ -89,59 +90,58 @@ func initContextWithAuthProxy(ctx *Context, orgId int64) bool { ctx.SignedInUser = query.Result ctx.IsSignedIn = true - ctx.Session.Set(SESS_KEY_USERID, ctx.UserId) + ctx.Session.Set(session.SESS_KEY_USERID, ctx.UserId) return true } -var syncGrafanaUserWithLdapUser = func(ctx *Context, query *m.GetSignedInUserQuery) error { - if setting.LdapEnabled { - expireEpoch := time.Now().Add(time.Duration(-setting.AuthProxyLdapSyncTtl) * time.Minute).Unix() +var syncGrafanaUserWithLdapUser = func(ctx *m.ReqContext, query *m.GetSignedInUserQuery) error { + if !setting.LdapEnabled { + return nil + } - var lastLdapSync int64 - if lastLdapSyncInSession := ctx.Session.Get(SESS_KEY_LASTLDAPSYNC); lastLdapSyncInSession != nil { - lastLdapSync = lastLdapSyncInSession.(int64) - } + expireEpoch := time.Now().Add(time.Duration(-setting.AuthProxyLdapSyncTtl) * time.Minute).Unix() - if lastLdapSync < expireEpoch { - ldapCfg := login.LdapCfg + var lastLdapSync int64 + if lastLdapSyncInSession := ctx.Session.Get(session.SESS_KEY_LASTLDAPSYNC); lastLdapSyncInSession != nil { + lastLdapSync = lastLdapSyncInSession.(int64) + } - for _, server := range ldapCfg.Servers { - author := login.NewLdapAuthenticator(server) - if err := author.SyncSignedInUser(query.Result); err != nil { - return err - } + if lastLdapSync < expireEpoch { + ldapCfg := login.LdapCfg + + for _, server := range ldapCfg.Servers { + author := login.NewLdapAuthenticator(server) + if err := author.SyncSignedInUser(query.Result); err != nil { + return err } - - ctx.Session.Set(SESS_KEY_LASTLDAPSYNC, time.Now().Unix()) } + + ctx.Session.Set(session.SESS_KEY_LASTLDAPSYNC, time.Now().Unix()) } return nil } -func checkAuthenticationProxy(ctx *Context, proxyHeaderValue string) error { - if len(strings.TrimSpace(setting.AuthProxyWhitelist)) > 0 { - proxies := strings.Split(setting.AuthProxyWhitelist, ",") - remoteAddrSplit := strings.Split(ctx.Req.RemoteAddr, ":") - sourceIP := remoteAddrSplit[0] +func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error { + if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { + return nil + } - found := false - for _, proxyIP := range proxies { - if sourceIP == strings.TrimSpace(proxyIP) { - found = true - break - } - } + proxies := strings.Split(setting.AuthProxyWhitelist, ",") + sourceIP, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return err + } - if !found { - msg := fmt.Sprintf("Request for user (%s) is not from the authentication proxy", proxyHeaderValue) - err := errors.New(msg) - return err + // Compare allowed IP addresses to actual address + for _, proxyIP := range proxies { + if sourceIP == strings.TrimSpace(proxyIP) { + return nil } } - return nil + return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP) } func getSignedInUserQueryForProxyAuth(headerVal string) *m.GetSignedInUserQuery { diff --git a/pkg/middleware/auth_proxy_test.go b/pkg/middleware/auth_proxy_test.go index 4da0f52bbcf..b3c011bd870 100644 --- a/pkg/middleware/auth_proxy_test.go +++ b/pkg/middleware/auth_proxy_test.go @@ -6,8 +6,10 @@ import ( "github.com/grafana/grafana/pkg/login" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" + "gopkg.in/macaron.v1" ) func TestAuthProxyWithLdapEnabled(t *testing.T) { @@ -29,45 +31,45 @@ func TestAuthProxyWithLdapEnabled(t *testing.T) { Convey("When session variable lastLdapSync not set, call syncSignedInUser and set lastLdapSync", func() { // arrange - session := mockSession{} - ctx := Context{Session: &session} - So(session.Get(SESS_KEY_LASTLDAPSYNC), ShouldBeNil) + sess := mockSession{} + ctx := m.ReqContext{Session: &sess} + So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeNil) // act syncGrafanaUserWithLdapUser(&ctx, &query) // assert So(mockLdapAuther.syncSignedInUserCalled, ShouldBeTrue) - So(session.Get(SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, 0) + So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, 0) }) Convey("When session variable not expired, don't sync and don't change session var", func() { // arrange - session := mockSession{} - ctx := Context{Session: &session} + sess := mockSession{} + ctx := m.ReqContext{Session: &sess} now := time.Now().Unix() - session.Set(SESS_KEY_LASTLDAPSYNC, now) + sess.Set(session.SESS_KEY_LASTLDAPSYNC, now) // act syncGrafanaUserWithLdapUser(&ctx, &query) // assert - So(session.Get(SESS_KEY_LASTLDAPSYNC), ShouldEqual, now) + So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldEqual, now) So(mockLdapAuther.syncSignedInUserCalled, ShouldBeFalse) }) Convey("When lastldapsync is expired, session variable should be updated", func() { // arrange - session := mockSession{} - ctx := Context{Session: &session} + sess := mockSession{} + ctx := m.ReqContext{Session: &sess} expiredTime := time.Now().Add(time.Duration(-120) * time.Minute).Unix() - session.Set(SESS_KEY_LASTLDAPSYNC, expiredTime) + sess.Set(session.SESS_KEY_LASTLDAPSYNC, expiredTime) // act syncGrafanaUserWithLdapUser(&ctx, &query) // assert - So(session.Get(SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, expiredTime) + So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, expiredTime) So(mockLdapAuther.syncSignedInUserCalled, ShouldBeTrue) }) }) @@ -77,7 +79,7 @@ type mockSession struct { value interface{} } -func (s *mockSession) Start(c *Context) error { +func (s *mockSession) Start(c *macaron.Context) error { return nil } @@ -102,11 +104,11 @@ func (s *mockSession) Release() error { return nil } -func (s *mockSession) Destory(c *Context) error { +func (s *mockSession) Destory(c *macaron.Context) error { return nil } -func (s *mockSession) RegenerateId(c *Context) error { +func (s *mockSession) RegenerateId(c *macaron.Context) error { return nil } diff --git a/pkg/middleware/dashboard_redirect.go b/pkg/middleware/dashboard_redirect.go index 4a3812fb8a2..2edf04d543e 100644 --- a/pkg/middleware/dashboard_redirect.go +++ b/pkg/middleware/dashboard_redirect.go @@ -6,11 +6,12 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" ) -func getDashboardUrlBySlug(orgId int64, slug string) (string, error) { - query := m.GetDashboardQuery{Slug: slug, OrgId: orgId} +func getDashboardURLBySlug(orgID int64, slug string) (string, error) { + query := m.GetDashboardQuery{Slug: slug, OrgId: orgID} if err := bus.Dispatch(&query); err != nil { return "", m.ErrDashboardNotFound @@ -19,12 +20,12 @@ func getDashboardUrlBySlug(orgId int64, slug string) (string, error) { return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil } -func RedirectFromLegacyDashboardUrl() macaron.Handler { - return func(c *Context) { +func RedirectFromLegacyDashboardURL() macaron.Handler { + return func(c *m.ReqContext) { slug := c.Params("slug") if slug != "" { - if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { + if url, err := getDashboardURLBySlug(c.OrgId, slug); err == nil { url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) c.Redirect(url, 301) return @@ -33,12 +34,17 @@ func RedirectFromLegacyDashboardUrl() macaron.Handler { } } -func RedirectFromLegacyDashboardSoloUrl() macaron.Handler { - return func(c *Context) { +func RedirectFromLegacyDashboardSoloURL() macaron.Handler { + return func(c *m.ReqContext) { slug := c.Params("slug") + renderRequest := c.QueryBool("render") if slug != "" { - if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { + if url, err := getDashboardURLBySlug(c.OrgId, slug); err == nil { + if renderRequest && strings.Contains(url, setting.AppSubUrl) { + url = strings.Replace(url, setting.AppSubUrl, "", 1) + } + url = strings.Replace(url, "/d/", "/d-solo/", 1) url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) c.Redirect(url, 301) diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go index 0af06347ed0..1e4d8293cae 100644 --- a/pkg/middleware/dashboard_redirect_test.go +++ b/pkg/middleware/dashboard_redirect_test.go @@ -13,8 +13,8 @@ import ( func TestMiddlewareDashboardRedirect(t *testing.T) { Convey("Given the dashboard redirect middleware", t, func() { bus.ClearBusHandlers() - redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl() - redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl() + redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardURL() + redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloURL() fakeDash := m.NewDashboard("Child dash") fakeDash.Id = 1 @@ -34,9 +34,9 @@ func TestMiddlewareDashboardRedirect(t *testing.T) { Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { So(sc.resp.Code, ShouldEqual, 301) - redirectUrl, _ := sc.resp.Result().Location() - So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)) - So(len(redirectUrl.Query()), ShouldEqual, 2) + redirectURL, _ := sc.resp.Result().Location() + So(redirectURL.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)) + So(len(redirectURL.Query()), ShouldEqual, 2) }) }) @@ -47,11 +47,11 @@ func TestMiddlewareDashboardRedirect(t *testing.T) { Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { So(sc.resp.Code, ShouldEqual, 301) - redirectUrl, _ := sc.resp.Result().Location() - expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) - expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1) - So(redirectUrl.Path, ShouldEqual, expectedUrl) - So(len(redirectUrl.Query()), ShouldEqual, 2) + redirectURL, _ := sc.resp.Result().Location() + expectedURL := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) + expectedURL = strings.Replace(expectedURL, "/d/", "/d-solo/", 1) + So(redirectURL.Path, ShouldEqual, expectedURL) + So(len(redirectURL.Query()), ShouldEqual, 2) }) }) }) diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 94f707800be..2c63810b9c8 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -19,6 +19,7 @@ import ( "net/http" "time" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" "gopkg.in/macaron.v1" @@ -47,7 +48,7 @@ func Logger() macaron.Handler { } if ctx, ok := c.Data["ctx"]; ok { - ctxTyped := ctx.(*Context) + ctxTyped := ctx.(*m.ReqContext) if status == 500 { ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer()) } else { diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index f0c952811cd..b5b244d5bff 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -2,7 +2,6 @@ package middleware import ( "strconv" - "strings" "gopkg.in/macaron.v1" @@ -11,29 +10,17 @@ import ( "github.com/grafana/grafana/pkg/log" l "github.com/grafana/grafana/pkg/login" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" - "github.com/prometheus/client_golang/prometheus" ) -type Context struct { - *macaron.Context - *m.SignedInUser - - Session SessionStore - - IsSignedIn bool - IsRenderCall bool - AllowAnonymous bool - Logger log.Logger -} - func GetContextHandler() macaron.Handler { return func(c *macaron.Context) { - ctx := &Context{ + ctx := &m.ReqContext{ Context: c, SignedInUser: &m.SignedInUser{}, - Session: GetSession(), + Session: session.GetSession(), IsSignedIn: false, AllowAnonymous: false, Logger: log.New("context"), @@ -74,7 +61,7 @@ func GetContextHandler() macaron.Handler { } } -func initContextWithAnonymousUser(ctx *Context) bool { +func initContextWithAnonymousUser(ctx *m.ReqContext) bool { if !setting.AnonymousEnabled { return false } @@ -94,9 +81,9 @@ func initContextWithAnonymousUser(ctx *Context) bool { return true } -func initContextWithUserSessionCookie(ctx *Context, orgId int64) bool { +func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool { // initialize session - if err := ctx.Session.Start(ctx); err != nil { + if err := ctx.Session.Start(ctx.Context); err != nil { ctx.Logger.Error("Failed to start session", "error", err) return false } @@ -117,7 +104,7 @@ func initContextWithUserSessionCookie(ctx *Context, orgId int64) bool { return true } -func initContextWithApiKey(ctx *Context) bool { +func initContextWithApiKey(ctx *m.ReqContext) bool { var keyString string if keyString = getApiKey(ctx); keyString == "" { return false @@ -153,7 +140,7 @@ func initContextWithApiKey(ctx *Context) bool { return true } -func initContextWithBasicAuth(ctx *Context, orgId int64) bool { +func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { if !setting.BasicAuthEnabled { return false @@ -195,70 +182,8 @@ func initContextWithBasicAuth(ctx *Context, orgId int64) bool { return true } -// Handle handles and logs error by given status. -func (ctx *Context) Handle(status int, title string, err error) { - if err != nil { - ctx.Logger.Error(title, "error", err) - if setting.Env != setting.PROD { - ctx.Data["ErrorMsg"] = err - } - } - - ctx.Data["Title"] = title - ctx.Data["AppSubUrl"] = setting.AppSubUrl - ctx.Data["Theme"] = "dark" - - ctx.HTML(status, "error") -} - -func (ctx *Context) JsonOK(message string) { - resp := make(map[string]interface{}) - resp["message"] = message - ctx.JSON(200, resp) -} - -func (ctx *Context) IsApiRequest() bool { - return strings.HasPrefix(ctx.Req.URL.Path, "/api") -} - -func (ctx *Context) JsonApiErr(status int, message string, err error) { - resp := make(map[string]interface{}) - - if err != nil { - ctx.Logger.Error(message, "error", err) - if setting.Env != setting.PROD { - resp["error"] = err.Error() - } - } - - switch status { - case 404: - resp["message"] = "Not Found" - case 500: - resp["message"] = "Internal Server Error" - } - - if message != "" { - resp["message"] = message - } - - ctx.JSON(status, resp) -} - -func (ctx *Context) HasUserRole(role m.RoleType) bool { - return ctx.OrgRole.Includes(role) -} - -func (ctx *Context) HasHelpFlag(flag m.HelpFlags1) bool { - return ctx.HelpFlags1.HasFlag(flag) -} - -func (ctx *Context) TimeRequest(timer prometheus.Summary) { - ctx.Data["perfmon.timer"] = timer -} - func AddDefaultResponseHeaders() macaron.Handler { - return func(ctx *Context) { + return func(ctx *m.ReqContext) { if ctx.IsApiRequest() && ctx.Req.Method == "GET" { ctx.Resp.Header().Add("Cache-Control", "no-cache") ctx.Resp.Header().Add("Pragma", "no-cache") diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index ffd8e8a0af0..f80a30de02f 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -7,10 +7,11 @@ import ( "path/filepath" "testing" - "github.com/go-macaron/session" + ms "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" l "github.com/grafana/grafana/pkg/login" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" . "github.com/smartystreets/goconvey/convey" @@ -130,8 +131,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("UserId in session", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *Context) { - c.Session.Set(SESS_KEY_USERID, int64(12)) + sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { + c.Session.Set(session.SESS_KEY_USERID, int64(12)) }).exec() bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { @@ -225,11 +226,11 @@ func TestMiddlewareContext(t *testing.T) { }) }) - middlewareScenario("When auth_proxy is enabled and request RemoteAddr is not trusted", func(sc *scenarioContext) { + middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is not trusted", func(sc *scenarioContext) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderProperty = "username" - setting.AuthProxyWhitelist = "192.168.1.1, 192.168.2.1" + setting.AuthProxyWhitelist = "192.168.1.1, 2001::23" sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") @@ -238,6 +239,24 @@ func TestMiddlewareContext(t *testing.T) { Convey("should return 407 status code", func() { So(sc.resp.Code, ShouldEqual, 407) + So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.3.1 is not from the authentication proxy") + }) + }) + + middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) { + setting.AuthProxyEnabled = true + setting.AuthProxyHeaderName = "X-WEBAUTH-USER" + setting.AuthProxyHeaderProperty = "username" + setting.AuthProxyWhitelist = "192.168.1.1, 2001::23" + + sc.fakeReq("GET", "/") + sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") + sc.req.RemoteAddr = "[2001:23]:12345" + sc.exec() + + Convey("should return 407 status code", func() { + So(sc.resp.Code, ShouldEqual, 407) + So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 2001:23 is not from the authentication proxy") }) }) @@ -245,7 +264,7 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderProperty = "username" - setting.AuthProxyWhitelist = "192.168.1.1, 192.168.2.1" + setting.AuthProxyWhitelist = "192.168.1.1, 2001::23" bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} @@ -254,7 +273,7 @@ func TestMiddlewareContext(t *testing.T) { sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") - sc.req.RemoteAddr = "192.168.2.1:12345" + sc.req.RemoteAddr = "[2001::23]:12345" sc.exec() Convey("Should init context with user info", func() { @@ -276,8 +295,8 @@ func TestMiddlewareContext(t *testing.T) { }) // create session - sc.fakeReq("GET", "/").handler(func(c *Context) { - c.Session.Set(SESS_KEY_USERID, int64(33)) + sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { + c.Session.Set(session.SESS_KEY_USERID, int64(33)) }).exec() oldSessionID := sc.context.Session.ID() @@ -300,7 +319,7 @@ func TestMiddlewareContext(t *testing.T) { setting.LdapEnabled = true called := false - syncGrafanaUserWithLdapUser = func(ctx *Context, query *m.GetSignedInUserQuery) error { + syncGrafanaUserWithLdapUser = func(ctx *m.ReqContext, query *m.GetSignedInUserQuery) error { called = true return nil } @@ -336,12 +355,12 @@ func middlewareScenario(desc string, fn scenarioFunc) { sc.m.Use(GetContextHandler()) // mock out gc goroutine - startSessionGC = func() {} - sc.m.Use(Sessioner(&session.Options{})) + session.StartSessionGC = func() {} + sc.m.Use(Sessioner(&ms.Options{}, 0)) sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) - sc.defaultHandler = func(c *Context) { + sc.defaultHandler = func(c *m.ReqContext) { sc.context = c if sc.handlerFunc != nil { sc.handlerFunc(sc.context) @@ -356,7 +375,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { type scenarioContext struct { m *macaron.Macaron - context *Context + context *m.ReqContext resp *httptest.ResponseRecorder apiKey string authHeader string @@ -436,4 +455,4 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *Context) +type handlerFunc func(c *m.ReqContext) diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index 9dd764be1bb..db263c2a17a 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" @@ -22,7 +22,7 @@ func OrgRedirect() macaron.Handler { return } - ctx, ok := c.Data["ctx"].(*Context) + ctx, ok := c.Data["ctx"].(*m.ReqContext) if !ok || !ctx.IsSignedIn { return } @@ -31,7 +31,7 @@ func OrgRedirect() macaron.Handler { return } - cmd := models.SetUsingOrgCommand{UserId: ctx.UserId, OrgId: orgId} + cmd := m.SetUsingOrgCommand{UserId: ctx.UserId, OrgId: orgId} if err := bus.Dispatch(&cmd); err != nil { if ctx.IsApiRequest() { ctx.JsonApiErr(404, "Not found", nil) diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 1f3d01f30f2..fa08154b250 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -6,7 +6,8 @@ import ( "fmt" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" . "github.com/smartystreets/goconvey/convey" ) @@ -14,16 +15,16 @@ func TestOrgRedirectMiddleware(t *testing.T) { Convey("Can redirect to correct org", t, func() { middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *Context) { - c.Session.Set(SESS_KEY_USERID, int64(12)) + sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { + c.Session.Set(session.SESS_KEY_USERID, int64(12)) }).exec() - bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return nil }) - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} return nil }) @@ -36,16 +37,16 @@ func TestOrgRedirectMiddleware(t *testing.T) { }) middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *Context) { - c.Session.Set(SESS_KEY_USERID, int64(12)) + sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { + c.Session.Set(session.SESS_KEY_USERID, int64(12)) }).exec() - bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return fmt.Errorf("") }) - bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} return nil }) diff --git a/pkg/middleware/perf.go b/pkg/middleware/perf.go index e381121a47f..5b6ab6f2d0a 100644 --- a/pkg/middleware/perf.go +++ b/pkg/middleware/perf.go @@ -4,9 +4,11 @@ import ( "net/http" "gopkg.in/macaron.v1" + + m "github.com/grafana/grafana/pkg/models" ) func MeasureRequestTime() macaron.Handler { - return func(res http.ResponseWriter, req *http.Request, c *Context) { + return func(res http.ResponseWriter, req *http.Request, c *m.ReqContext) { } } diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 23f98e78a7e..43efca43485 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -3,15 +3,15 @@ package middleware import ( "fmt" - "github.com/grafana/grafana/pkg/bus" - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" ) func Quota(target string) macaron.Handler { - return func(c *Context) { - limitReached, err := QuotaReached(c, target) + return func(c *m.ReqContext) { + limitReached, err := quota.QuotaReached(c, target) if err != nil { c.JsonApiErr(500, "failed to get quota", err) return @@ -22,82 +22,3 @@ func Quota(target string) macaron.Handler { } } } - -func QuotaReached(c *Context, target string) (bool, error) { - if !setting.Quota.Enabled { - return false, nil - } - - // get the list of scopes that this target is valid for. Org, User, Global - scopes, err := m.GetQuotaScopes(target) - if err != nil { - return false, err - } - - for _, scope := range scopes { - c.Logger.Debug("Checking quota", "target", target, "scope", scope) - - switch scope.Name { - case "global": - if scope.DefaultLimit < 0 { - continue - } - if scope.DefaultLimit == 0 { - return true, nil - } - if target == "session" { - usedSessions := getSessionCount() - if int64(usedSessions) > scope.DefaultLimit { - c.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) - return true, nil - } - continue - } - query := m.GetGlobalQuotaByTargetQuery{Target: scope.Target} - if err := bus.Dispatch(&query); err != nil { - return true, err - } - if query.Result.Used >= scope.DefaultLimit { - return true, nil - } - case "org": - if !c.IsSignedIn { - continue - } - query := m.GetOrgQuotaByTargetQuery{OrgId: c.OrgId, Target: scope.Target, Default: scope.DefaultLimit} - if err := bus.Dispatch(&query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { - return true, nil - } - case "user": - if !c.IsSignedIn || c.UserId == 0 { - continue - } - query := m.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit} - if err := bus.Dispatch(&query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { - return true, nil - } - } - } - - return false, nil -} diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index b68aa485fa7..92c3d62674d 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) @@ -12,7 +13,7 @@ import ( func TestMiddlewareQuota(t *testing.T) { Convey("Given the grafana quota middleware", t, func() { - getSessionCount = func() int { + session.GetSessionCount = func() int { return 4 } @@ -74,8 +75,8 @@ func TestMiddlewareQuota(t *testing.T) { middlewareScenario("with user logged in", func(sc *scenarioContext) { // log us in, so we have a user_id and org_id in the context - sc.fakeReq("GET", "/").handler(func(c *Context) { - c.Session.Set(SESS_KEY_USERID, int64(12)) + sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { + c.Session.Set(session.SESS_KEY_USERID, int64(12)) }).exec() bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 388acc15afc..ec289387aa4 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -24,6 +24,7 @@ import ( "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -106,7 +107,7 @@ func Recovery() macaron.Handler { panicLogger := log.Root // try to get request logger if ctx, ok := c.Data["ctx"]; ok { - ctxTyped := ctx.(*Context) + ctxTyped := ctx.(*m.ReqContext) panicLogger = ctxTyped.Logger } @@ -115,15 +116,15 @@ func Recovery() macaron.Handler { c.Data["Title"] = "Server Error" c.Data["AppSubUrl"] = setting.AppSubUrl - if theErr, ok := err.(error); ok { - c.Data["Title"] = theErr.Error() - } - if setting.Env == setting.DEV { + if theErr, ok := err.(error); ok { + c.Data["Title"] = theErr.Error() + } + c.Data["ErrorMsg"] = string(stack) } - ctx, ok := c.Data["ctx"].(*Context) + ctx, ok := c.Data["ctx"].(*m.ReqContext) if ok && ctx.IsApiRequest() { resp := make(map[string]interface{}) diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 299186945ee..4bbedbc3b21 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -4,18 +4,20 @@ import ( "path/filepath" "testing" - "github.com/go-macaron/session" + ms "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" . "github.com/smartystreets/goconvey/convey" "gopkg.in/macaron.v1" ) func TestRecoveryMiddleware(t *testing.T) { Convey("Given an api route that panics", t, func() { - apiUrl := "/api/whatever" - recoveryScenario("recovery middleware should return json", apiUrl, func(sc *scenarioContext) { + apiURL := "/api/whatever" + recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) { sc.handlerFunc = PanicHandler - sc.fakeReq("GET", apiUrl).exec() + sc.fakeReq("GET", apiURL).exec() sc.req.Header.Add("content-type", "application/json") So(sc.resp.Code, ShouldEqual, 500) @@ -25,10 +27,10 @@ func TestRecoveryMiddleware(t *testing.T) { }) Convey("Given a non-api route that panics", t, func() { - apiUrl := "/whatever" - recoveryScenario("recovery middleware should return html", apiUrl, func(sc *scenarioContext) { + apiURL := "/whatever" + recoveryScenario("recovery middleware should return html", apiURL, func(sc *scenarioContext) { sc.handlerFunc = PanicHandler - sc.fakeReq("GET", apiUrl).exec() + sc.fakeReq("GET", apiURL).exec() So(sc.resp.Code, ShouldEqual, 500) So(sc.resp.Header().Get("content-type"), ShouldEqual, "text/html; charset=UTF-8") @@ -37,7 +39,7 @@ func TestRecoveryMiddleware(t *testing.T) { }) } -func PanicHandler(c *Context) { +func PanicHandler(c *m.ReqContext) { panic("Handler has panicked") } @@ -60,12 +62,12 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { sc.m.Use(GetContextHandler()) // mock out gc goroutine - startSessionGC = func() {} - sc.m.Use(Sessioner(&session.Options{})) + session.StartSessionGC = func() {} + sc.m.Use(Sessioner(&ms.Options{}, 0)) sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) - sc.defaultHandler = func(c *Context) { + sc.defaultHandler = func(c *m.ReqContext) { sc.context = c if sc.handlerFunc != nil { sc.handlerFunc(sc.context) diff --git a/pkg/middleware/render_auth.go b/pkg/middleware/render_auth.go index d2f9c1b2b1a..6c338becbda 100644 --- a/pkg/middleware/render_auth.go +++ b/pkg/middleware/render_auth.go @@ -10,7 +10,7 @@ import ( var renderKeysLock sync.Mutex var renderKeys map[string]*m.SignedInUser = make(map[string]*m.SignedInUser) -func initContextWithRenderAuth(ctx *Context) bool { +func initContextWithRenderAuth(ctx *m.ReqContext) bool { key := ctx.GetCookie("renderKey") if key == "" { return false @@ -19,16 +19,16 @@ func initContextWithRenderAuth(ctx *Context) bool { renderKeysLock.Lock() defer renderKeysLock.Unlock() - if renderUser, exists := renderKeys[key]; !exists { + renderUser, exists := renderKeys[key] + if !exists { ctx.JsonApiErr(401, "Invalid Render Key", nil) return true - } else { - - ctx.IsSignedIn = true - ctx.SignedInUser = renderUser - ctx.IsRenderCall = true - return true } + + ctx.IsSignedIn = true + ctx.SignedInUser = renderUser + ctx.IsRenderCall = true + return true } type renderContextFunc func(key string) (string, error) diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go index 4de111ff3d2..19cfa368b49 100644 --- a/pkg/middleware/session.go +++ b/pkg/middleware/session.go @@ -1,170 +1,21 @@ package middleware import ( - "math/rand" - "time" - - "github.com/go-macaron/session" - _ "github.com/go-macaron/session/memcache" - _ "github.com/go-macaron/session/mysql" - _ "github.com/go-macaron/session/postgres" - _ "github.com/go-macaron/session/redis" - "github.com/grafana/grafana/pkg/log" + ms "github.com/go-macaron/session" "gopkg.in/macaron.v1" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" ) -const ( - SESS_KEY_USERID = "uid" - SESS_KEY_OAUTH_STATE = "state" - SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys - SESS_KEY_LASTLDAPSYNC = "last_ldap_sync" -) +func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler { + session.Init(options, sessionConnMaxLifetime) -var sessionManager *session.Manager -var sessionOptions *session.Options -var startSessionGC func() -var getSessionCount func() int -var sessionLogger = log.New("session") - -func init() { - startSessionGC = func() { - sessionManager.GC() - sessionLogger.Debug("Session GC") - time.AfterFunc(time.Duration(sessionOptions.Gclifetime)*time.Second, startSessionGC) - } - getSessionCount = func() int { - return sessionManager.Count() - } -} - -func prepareOptions(opt *session.Options) *session.Options { - if len(opt.Provider) == 0 { - opt.Provider = "memory" - } - if len(opt.ProviderConfig) == 0 { - opt.ProviderConfig = "data/sessions" - } - if len(opt.CookieName) == 0 { - opt.CookieName = "grafana_sess" - } - if len(opt.CookiePath) == 0 { - opt.CookiePath = "/" - } - if opt.Gclifetime == 0 { - opt.Gclifetime = 3600 - } - if opt.Maxlifetime == 0 { - opt.Maxlifetime = opt.Gclifetime - } - if opt.IDLength == 0 { - opt.IDLength = 16 - } - - return opt -} - -func Sessioner(options *session.Options) macaron.Handler { - var err error - sessionOptions = prepareOptions(options) - sessionManager, err = session.NewManager(options.Provider, *options) - if err != nil { - panic(err) - } - - // start GC threads after some random seconds - rndSeconds := 10 + rand.Int63n(180) - time.AfterFunc(time.Duration(rndSeconds)*time.Second, startSessionGC) - - return func(ctx *Context) { + return func(ctx *m.ReqContext) { ctx.Next() - if err = ctx.Session.Release(); err != nil { + if err := ctx.Session.Release(); err != nil { panic("session(release): " + err.Error()) } } } - -func GetSession() SessionStore { - return &SessionWrapper{manager: sessionManager} -} - -type SessionStore interface { - // Set sets value to given key in session. - Set(interface{}, interface{}) error - // Get gets value by given key in session. - Get(interface{}) interface{} - // Delete deletes a key from session. - Delete(interface{}) interface{} - // ID returns current session ID. - ID() string - // Release releases session resource and save data to provider. - Release() error - // Destory deletes a session. - Destory(*Context) error - // init - Start(*Context) error - // RegenerateId regenerates the session id - RegenerateId(*Context) error -} - -type SessionWrapper struct { - session session.RawStore - manager *session.Manager -} - -func (s *SessionWrapper) Start(c *Context) error { - var err error - s.session, err = s.manager.Start(c.Context) - return err -} - -func (s *SessionWrapper) RegenerateId(c *Context) error { - var err error - s.session, err = s.manager.RegenerateId(c.Context) - return err -} - -func (s *SessionWrapper) Set(k interface{}, v interface{}) error { - if s.session != nil { - return s.session.Set(k, v) - } - return nil -} - -func (s *SessionWrapper) Get(k interface{}) interface{} { - if s.session != nil { - return s.session.Get(k) - } - return nil -} - -func (s *SessionWrapper) Delete(k interface{}) interface{} { - if s.session != nil { - return s.session.Delete(k) - } - return nil -} - -func (s *SessionWrapper) ID() string { - if s.session != nil { - return s.session.ID() - } - return "" -} - -func (s *SessionWrapper) Release() error { - if s.session != nil { - return s.session.Release() - } - return nil -} - -func (s *SessionWrapper) Destory(c *Context) error { - if s.session != nil { - if err := s.manager.Destory(c.Context); err != nil { - return err - } - s.session = nil - } - return nil -} diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go index fa84e783767..63c4b3000e9 100644 --- a/pkg/middleware/validate_host.go +++ b/pkg/middleware/validate_host.go @@ -3,12 +3,13 @@ package middleware import ( "strings" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" ) func ValidateHostHeader(domain string) macaron.Handler { - return func(c *Context) { + return func(c *m.ReqContext) { // ignore local render calls if c.IsRenderCall { return diff --git a/pkg/models/alert.go b/pkg/models/alert.go index b378c5cf90f..88b49350b97 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -166,8 +166,9 @@ type GetAlertsQuery struct { DashboardId int64 PanelId int64 Limit int64 + User *SignedInUser - Result []*Alert + Result []*AlertListItemDTO } type GetAllAlertsQuery struct { @@ -187,6 +188,21 @@ type GetAlertStatesForDashboardQuery struct { Result []*AlertStateInfoDTO } +type AlertListItemDTO struct { + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + DashboardUid string `json:"dashboardUid"` + DashboardSlug string `json:"dashboardSlug"` + PanelId int64 `json:"panelId"` + Name string `json:"name"` + State AlertStateType `json:"state"` + NewStateDate time.Time `json:"newStateDate"` + EvalDate time.Time `json:"evalDate"` + EvalData *simplejson.Json `json:"evalData"` + ExecutionError string `json:"executionError"` + Url string `json:"url"` +} + type AlertStateInfoDTO struct { Id int64 `json:"id"` DashboardId int64 `json:"dashboardId"` @@ -194,3 +210,17 @@ type AlertStateInfoDTO struct { State AlertStateType `json:"state"` NewStateDate time.Time `json:"newStateDate"` } + +// "Internal" commands + +type UpdateDashboardAlertsCommand struct { + UserId int64 + OrgId int64 + Dashboard *Dashboard +} + +type ValidateDashboardAlertsCommand struct { + UserId int64 + OrgId int64 + Dashboard *Dashboard +} diff --git a/pkg/models/context.go b/pkg/models/context.go new file mode 100644 index 00000000000..262f6550954 --- /dev/null +++ b/pkg/models/context.go @@ -0,0 +1,86 @@ +package models + +import ( + "strings" + + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/macaron.v1" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/session" + "github.com/grafana/grafana/pkg/setting" +) + +type ReqContext struct { + *macaron.Context + *SignedInUser + + Session session.SessionStore + + IsSignedIn bool + IsRenderCall bool + AllowAnonymous bool + Logger log.Logger +} + +// Handle handles and logs error by given status. +func (ctx *ReqContext) Handle(status int, title string, err error) { + if err != nil { + ctx.Logger.Error(title, "error", err) + if setting.Env != setting.PROD { + ctx.Data["ErrorMsg"] = err + } + } + + ctx.Data["Title"] = title + ctx.Data["AppSubUrl"] = setting.AppSubUrl + ctx.Data["Theme"] = "dark" + + ctx.HTML(status, "error") +} + +func (ctx *ReqContext) JsonOK(message string) { + resp := make(map[string]interface{}) + resp["message"] = message + ctx.JSON(200, resp) +} + +func (ctx *ReqContext) IsApiRequest() bool { + return strings.HasPrefix(ctx.Req.URL.Path, "/api") +} + +func (ctx *ReqContext) JsonApiErr(status int, message string, err error) { + resp := make(map[string]interface{}) + + if err != nil { + ctx.Logger.Error(message, "error", err) + if setting.Env != setting.PROD { + resp["error"] = err.Error() + } + } + + switch status { + case 404: + resp["message"] = "Not Found" + case 500: + resp["message"] = "Internal Server Error" + } + + if message != "" { + resp["message"] = message + } + + ctx.JSON(status, resp) +} + +func (ctx *ReqContext) HasUserRole(role RoleType) bool { + return ctx.OrgRole.Includes(role) +} + +func (ctx *ReqContext) HasHelpFlag(flag HelpFlags1) bool { + return ctx.HelpFlags1.HasFlag(flag) +} + +func (ctx *ReqContext) TimeRequest(timer prometheus.Summary) { + ctx.Data["perfmon.timer"] = timer +} diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 0e14a3bfd71..4ef8061486b 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -26,6 +26,8 @@ func (p PermissionType) String() string { var ( ErrDashboardAclInfoMissing = errors.New("User id and team id cannot both be empty for a dashboard permission.") ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.") + ErrFolderAclInfoMissing = errors.New("User id and team id cannot both be empty for a folder permission.") + ErrFolderPermissionFolderEmpty = errors.New("Folder Id must be greater than zero for a folder permission.") ) // Dashboard ACL model @@ -45,7 +47,8 @@ type DashboardAcl struct { type DashboardAclInfoDTO struct { OrgId int64 `json:"-"` - DashboardId int64 `json:"dashboardId"` + DashboardId int64 `json:"dashboardId,omitempty"` + FolderId int64 `json:"folderId,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` @@ -68,6 +71,27 @@ type DashboardAclInfoDTO struct { Url string `json:"url"` } +func (dto *DashboardAclInfoDTO) hasSameRoleAs(other *DashboardAclInfoDTO) bool { + if dto.Role == nil || other.Role == nil { + return false + } + + return dto.UserId <= 0 && dto.TeamId <= 0 && dto.UserId == other.UserId && dto.TeamId == other.TeamId && *dto.Role == *other.Role +} + +func (dto *DashboardAclInfoDTO) hasSameUserAs(other *DashboardAclInfoDTO) bool { + return dto.UserId > 0 && dto.UserId == other.UserId +} + +func (dto *DashboardAclInfoDTO) hasSameTeamAs(other *DashboardAclInfoDTO) bool { + return dto.TeamId > 0 && dto.TeamId == other.TeamId +} + +// IsDuplicateOf returns true if other item has same role, same user or same team +func (dto *DashboardAclInfoDTO) IsDuplicateOf(other *DashboardAclInfoDTO) bool { + return dto.hasSameRoleAs(other) || dto.hasSameUserAs(other) || dto.hasSameTeamAs(other) +} + // // COMMANDS // diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index 9273b88f291..ec8b19f3c18 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -64,10 +64,12 @@ type DeleteDashboardSnapshotCommand struct { } type DeleteExpiredSnapshotsCommand struct { + DeletedRows int64 } type GetDashboardSnapshotQuery struct { - Key string + Key string + DeleteKey string Result *DashboardSnapshot } @@ -76,9 +78,10 @@ type DashboardSnapshots []*DashboardSnapshot type DashboardSnapshotsList []*DashboardSnapshotDTO type GetDashboardSnapshotsQuery struct { - Name string - Limit int - OrgId int64 + Name string + Limit int + OrgId int64 + SignedInUser *SignedInUser Result DashboardSnapshotsList } diff --git a/pkg/models/dashboard_version.go b/pkg/models/dashboard_version.go index 4acb4282a58..9f5f18cb263 100644 --- a/pkg/models/dashboard_version.go +++ b/pkg/models/dashboard_version.go @@ -75,4 +75,5 @@ type GetDashboardVersionsQuery struct { // type DeleteExpiredVersionsCommand struct { + DeletedRows int64 } diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 9809fdab9eb..8cd2b01811c 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -13,22 +13,26 @@ import ( // Typed errors var ( - ErrDashboardNotFound = errors.New("Dashboard not found") - ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") - ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") - ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") - ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") - ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") - ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") - ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") - ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") - ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") - ErrDashboardExistingCannotChangeToDashboard = errors.New("An existing folder cannot be changed to a dashboard") - ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") - ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") - ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") - RootFolderName = "General" + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrDashboardFolderNotFound = errors.New("Folder not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") + ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") + ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") + ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") + ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") + ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") + ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") + ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") + ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") + ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") + ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") + ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") + ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") + ErrDashboardFolderNameExists = errors.New("A folder with that name already exists") + ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard") + ErrDashboardInvalidUid = errors.New("uid contains illegal characters") + ErrDashboardUidToLong = errors.New("uid to long. max 40 characters") + RootFolderName = "General" ) type UpdatePluginDashboardError struct { @@ -74,6 +78,25 @@ func (d *Dashboard) SetId(id int64) { d.Data.Set("id", id) } +func (d *Dashboard) SetUid(uid string) { + d.Uid = uid + d.Data.Set("uid", uid) +} + +func (d *Dashboard) SetVersion(version int) { + d.Version = version + d.Data.Set("version", version) +} + +// GetDashboardIdForSavePermissionCheck return the dashboard id to be used for checking permission of dashboard +func (d *Dashboard) GetDashboardIdForSavePermissionCheck() int64 { + if d.Id == 0 { + return d.FolderId + } + + return d.Id +} + // NewDashboard creates a new dashboard func NewDashboard(title string) *Dashboard { dash := &Dashboard{} @@ -89,9 +112,10 @@ func NewDashboard(title string) *Dashboard { // NewDashboardFolder creates a new dashboard folder func NewDashboardFolder(title string) *Dashboard { folder := NewDashboard(title) + folder.IsFolder = true folder.Data.Set("schemaVersion", 16) - folder.Data.Set("editable", true) - folder.Data.Set("hideControls", true) + folder.Data.Set("version", 0) + folder.IsFolder = true return folder } @@ -142,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { userId = -1 } - if dash.Data.Get("version").MustInt(0) == 0 { - dash.CreatedBy = userId - } - dash.UpdatedBy = userId dash.OrgId = cmd.OrgId dash.PluginId = cmd.PluginId @@ -189,14 +209,14 @@ func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string { return GetDashboardUrl(uid, slug) } -// Return the html url for a dashboard +// GetDashboardUrl return the html url for a dashboard func GetDashboardUrl(uid string, slug string) string { return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug) } -// Return the full url for a dashboard +// GetFullDashboardUrl return the full url for a dashboard func GetFullDashboardUrl(uid string, slug string) string { - return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug)) + return fmt.Sprintf("%sd/%s/%s", setting.AppUrl, uid, slug) } // GetFolderUrl return the html url for a folder @@ -229,7 +249,7 @@ type DashboardProvisioning struct { DashboardId int64 Name string ExternalId string - Updated time.Time + Updated int64 } type SaveProvisionedDashboardCommand struct { @@ -244,6 +264,12 @@ type DeleteDashboardCommand struct { OrgId int64 } +type ValidateDashboardBeforeSaveCommand struct { + OrgId int64 + Dashboard *Dashboard + Overwrite bool +} + // // QUERIES // diff --git a/pkg/models/dashboards_test.go b/pkg/models/dashboards_test.go index ad865b575bb..69bc8ab7bd9 100644 --- a/pkg/models/dashboards_test.go +++ b/pkg/models/dashboards_test.go @@ -4,11 +4,24 @@ import ( "testing" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) func TestDashboardModel(t *testing.T) { + Convey("Generate full dashboard url", t, func() { + setting.AppUrl = "http://grafana.local/" + fullUrl := GetFullDashboardUrl("uid", "my-dashboard") + So(fullUrl, ShouldEqual, "http://grafana.local/d/uid/my-dashboard") + }) + + Convey("Generate relative dashboard url", t, func() { + setting.AppUrl = "" + fullUrl := GetDashboardUrl("uid", "my-dashboard") + So(fullUrl, ShouldEqual, "/d/uid/my-dashboard") + }) + Convey("When generating slug", t, func() { dashboard := NewDashboard("Grafana Play Home") dashboard.UpdateSlug() diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 9c1cb6fe9e2..f2236ad8477 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -19,6 +19,7 @@ const ( DS_PROMETHEUS = "prometheus" DS_POSTGRES = "postgres" DS_MYSQL = "mysql" + DS_MSSQL = "mssql" DS_ACCESS_DIRECT = "direct" DS_ACCESS_PROXY = "proxy" ) @@ -58,21 +59,23 @@ type DataSource struct { } var knownDatasourcePlugins map[string]bool = map[string]bool{ - DS_ES: true, - DS_GRAPHITE: true, - DS_INFLUXDB: true, - DS_INFLUXDB_08: true, - DS_KAIROSDB: true, - DS_CLOUDWATCH: true, - DS_PROMETHEUS: true, - DS_OPENTSDB: true, - DS_POSTGRES: true, - DS_MYSQL: true, - "opennms": true, - "druid": true, - "dalmatinerdb": true, - "gnocci": true, - "zabbix": true, + DS_ES: true, + DS_GRAPHITE: true, + DS_INFLUXDB: true, + DS_INFLUXDB_08: true, + DS_KAIROSDB: true, + DS_CLOUDWATCH: true, + DS_PROMETHEUS: true, + DS_OPENTSDB: true, + DS_POSTGRES: true, + DS_MYSQL: true, + DS_MSSQL: true, + "opennms": true, + "abhisant-druid-datasource": true, + "dalmatinerdb-datasource": true, + "gnocci": true, + "zabbix": true, + "alexanderzobnin-zabbix-datasource": true, "newrelic-app": true, "grafana-datadog-datasource": true, "grafana-simple-json": true, diff --git a/pkg/models/folders.go b/pkg/models/folders.go new file mode 100644 index 00000000000..c61620a11fc --- /dev/null +++ b/pkg/models/folders.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "strings" + "time" +) + +// Typed errors +var ( + ErrFolderNotFound = errors.New("Folder not found") + ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") + ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") + ErrFolderWithSameUIDExists = errors.New("A folder/dashboard with the same uid already exists") + ErrFolderSameNameExists = errors.New("A folder or dashboard in the general folder with the same name already exists") + ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id") + ErrFolderAccessDenied = errors.New("Access denied to folder") +) + +type Folder struct { + Id int64 + Uid string + Title string + Url string + Version int + + Created time.Time + Updated time.Time + + UpdatedBy int64 + CreatedBy int64 + HasAcl bool +} + +// GetDashboardModel turns the command into the savable model +func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard { + dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title)) + dashFolder.OrgId = orgId + dashFolder.SetUid(strings.TrimSpace(cmd.Uid)) + + if userId == 0 { + userId = -1 + } + + dashFolder.CreatedBy = userId + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() + + return dashFolder +} + +// UpdateDashboardModel updates an existing model from command into model for update +func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard, orgId int64, userId int64) { + dashFolder.OrgId = orgId + dashFolder.Title = strings.TrimSpace(cmd.Title) + dashFolder.Data.Set("title", dashFolder.Title) + + if cmd.Uid != "" { + dashFolder.SetUid(cmd.Uid) + } + + dashFolder.SetVersion(cmd.Version) + dashFolder.IsFolder = true + + if userId == 0 { + userId = -1 + } + + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() +} + +// +// COMMANDS +// + +type CreateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + + Result *Folder +} + +type UpdateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + Version int `json:"version"` + Overwrite bool `json:"overwrite"` + + Result *Folder +} diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go index e4391927702..6e0976bc506 100644 --- a/pkg/models/login_attempt.go +++ b/pkg/models/login_attempt.go @@ -8,7 +8,7 @@ type LoginAttempt struct { Id int64 Username string IpAddress string - Created time.Time + Created int64 } // --------------------- diff --git a/pkg/models/team.go b/pkg/models/team.go index f789f125aa1..9c679a13394 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -7,8 +7,9 @@ import ( // Typed errors var ( - ErrTeamNotFound = errors.New("Team not found") - ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamNotFound = errors.New("Team not found") + ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamMemberNotFound = errors.New("Team member not found") ) // Team model diff --git a/pkg/plugins/dashboard_importer.go b/pkg/plugins/dashboard_importer.go index 9036b943b30..fb4d63a1fe4 100644 --- a/pkg/plugins/dashboard_importer.go +++ b/pkg/plugins/dashboard_importer.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" ) type ImportDashboardCommand struct { @@ -17,7 +18,7 @@ type ImportDashboardCommand struct { Overwrite bool OrgId int64 - UserId int64 + User *m.SignedInUser PluginId string Result *PluginDashboardInfoDTO } @@ -34,7 +35,7 @@ type DashboardInputMissingError struct { } func (e DashboardInputMissingError) Error() string { - return fmt.Sprintf("Dashbord input variable: %v missing from import command", e.VariableName) + return fmt.Sprintf("Dashboard input variable: %v missing from import command", e.VariableName) } func init() { @@ -66,23 +67,32 @@ func ImportDashboard(cmd *ImportDashboardCommand) error { saveCmd := m.SaveDashboardCommand{ Dashboard: generatedDash, OrgId: cmd.OrgId, - UserId: cmd.UserId, + UserId: cmd.User.UserId, Overwrite: cmd.Overwrite, PluginId: cmd.PluginId, FolderId: dashboard.FolderId, } - if err := bus.Dispatch(&saveCmd); err != nil { + dto := &dashboards.SaveDashboardDTO{ + OrgId: cmd.OrgId, + Dashboard: saveCmd.GetDashboardModel(), + Overwrite: saveCmd.Overwrite, + User: cmd.User, + } + + savedDash, err := dashboards.NewService().ImportDashboard(dto) + + if err != nil { return err } cmd.Result = &PluginDashboardInfoDTO{ PluginId: cmd.PluginId, - Title: dashboard.Title, + Title: savedDash.Title, Path: cmd.Path, - Revision: dashboard.Data.Get("revision").MustInt64(1), - ImportedUri: "db/" + saveCmd.Result.Slug, - ImportedUrl: saveCmd.Result.GetUrl(), + Revision: savedDash.Data.Get("revision").MustInt64(1), + ImportedUri: "db/" + savedDash.Slug, + ImportedUrl: savedDash.GetUrl(), ImportedRevision: dashboard.Data.Get("revision").MustInt64(1), Imported: true, } diff --git a/pkg/plugins/dashboard_importer_test.go b/pkg/plugins/dashboard_importer_test.go index a13dc8fe0a5..549b3bb4cf9 100644 --- a/pkg/plugins/dashboard_importer_test.go +++ b/pkg/plugins/dashboard_importer_test.go @@ -5,9 +5,9 @@ import ( "io/ioutil" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" "gopkg.in/ini.v1" @@ -15,19 +15,15 @@ import ( func TestDashboardImport(t *testing.T) { pluginScenario("When importing a plugin dashboard", t, func() { - var importedDash *m.Dashboard - - bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { - importedDash = cmd.GetDashboardModel() - cmd.Result = importedDash - return nil - }) + origNewDashboardService := dashboards.NewService + mock := &dashboards.FakeDashboardService{} + dashboards.MockDashboardService(mock) cmd := ImportDashboardCommand{ PluginId: "test-app", Path: "dashboards/connections.json", OrgId: 1, - UserId: 1, + User: &m.SignedInUser{UserId: 1, OrgRole: m.ROLE_ADMIN}, Inputs: []ImportDashboardInput{ {Name: "*", Type: "datasource", Value: "graphite"}, }, @@ -37,18 +33,22 @@ func TestDashboardImport(t *testing.T) { So(err, ShouldBeNil) Convey("should install dashboard", func() { - So(importedDash, ShouldNotBeNil) + So(cmd.Result, ShouldNotBeNil) - resultStr, _ := importedDash.Data.EncodePretty() + resultStr, _ := mock.SavedDashboards[0].Dashboard.Data.EncodePretty() expectedBytes, _ := ioutil.ReadFile("../../tests/test-app/dashboards/connections_result.json") expectedJson, _ := simplejson.NewJson(expectedBytes) expectedStr, _ := expectedJson.EncodePretty() So(string(resultStr), ShouldEqual, string(expectedStr)) - panel := importedDash.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0) + panel := mock.SavedDashboards[0].Dashboard.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0) So(panel.Get("datasource").MustString(), ShouldEqual, "graphite") }) + + Reset(func() { + dashboards.NewService = origNewDashboardService + }) }) Convey("When evaling dashboard template", t, func() { @@ -84,7 +84,6 @@ func TestDashboardImport(t *testing.T) { }) }) - } func pluginScenario(desc string, t *testing.T, fn func()) { diff --git a/pkg/plugins/dashboards_updater.go b/pkg/plugins/dashboards_updater.go index 4c40e536d14..835e8873810 100644 --- a/pkg/plugins/dashboards_updater.go +++ b/pkg/plugins/dashboards_updater.go @@ -47,7 +47,7 @@ func autoUpdateAppDashboard(pluginDashInfo *PluginDashboardInfoDTO, orgId int64) PluginId: pluginDashInfo.PluginId, Overwrite: true, Dashboard: dash.Data, - UserId: 0, + User: &m.SignedInUser{UserId: 0, OrgRole: m.ROLE_ADMIN}, Path: pluginDashInfo.Path, } diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index a2594ad915a..68ccdeaf840 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -63,7 +63,7 @@ func checkForUpdates() { resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) if err != nil { - log.Trace("Failed to get plugins repo from grafana.net, %v", err.Error()) + log.Trace("Failed to get plugins repo from grafana.com, %v", err.Error()) return } @@ -101,7 +101,7 @@ func checkForUpdates() { resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json") if err != nil { - log.Trace("Failed to get latest.json repo from github: %v", err.Error()) + log.Trace("Failed to get latest.json repo from github.com: %v", err.Error()) return } @@ -115,7 +115,7 @@ func checkForUpdates() { var githubLatest GithubLatest err = json.Unmarshal(body, &githubLatest) if err != nil { - log.Trace("Failed to unmarshal github latest, reading response from github: %v", err.Error()) + log.Trace("Failed to unmarshal github.com latest, reading response from github.com: %v", err.Error()) return } diff --git a/pkg/services/alerting/commands.go b/pkg/services/alerting/commands.go index 62671a559fa..02186d697ee 100644 --- a/pkg/services/alerting/commands.go +++ b/pkg/services/alerting/commands.go @@ -5,34 +5,18 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -type UpdateDashboardAlertsCommand struct { - UserId int64 - OrgId int64 - Dashboard *m.Dashboard -} - -type ValidateDashboardAlertsCommand struct { - UserId int64 - OrgId int64 - Dashboard *m.Dashboard -} - func init() { bus.AddHandler("alerting", updateDashboardAlerts) bus.AddHandler("alerting", validateDashboardAlerts) } -func validateDashboardAlerts(cmd *ValidateDashboardAlertsCommand) error { +func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error { extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) - if _, err := extractor.GetAlerts(); err != nil { - return err - } - - return nil + return extractor.ValidateAlerts() } -func updateDashboardAlerts(cmd *UpdateDashboardAlertsCommand) error { +func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error { saveAlerts := m.SaveAlertsCommand{ OrgId: cmd.OrgId, UserId: cmd.UserId, @@ -41,15 +25,12 @@ func updateDashboardAlerts(cmd *UpdateDashboardAlertsCommand) error { extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) - if alerts, err := extractor.GetAlerts(); err != nil { - return err - } else { - saveAlerts.Alerts = alerts - } - - if err := bus.Dispatch(&saveAlerts); err != nil { + alerts, err := extractor.GetAlerts() + if err != nil { return err } - return nil + saveAlerts.Alerts = alerts + + return bus.Dispatch(&saveAlerts) } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index 4448a5cb978..a6f97333d1b 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -86,17 +86,63 @@ func (e *Engine) runJobDispatcher(grafanaCtx context.Context) error { case <-grafanaCtx.Done(): return dispatcherGroup.Wait() case job := <-e.execQueue: - dispatcherGroup.Go(func() error { return e.processJob(alertCtx, job) }) + dispatcherGroup.Go(func() error { return e.processJobWithRetry(alertCtx, job) }) } } } var ( unfinishedWorkTimeout time.Duration = time.Second * 5 - alertTimeout time.Duration = time.Second * 30 + // TODO: Make alertTimeout and alertMaxAttempts configurable in the config file. + alertTimeout time.Duration = time.Second * 30 + alertMaxAttempts int = 3 ) -func (e *Engine) processJob(grafanaCtx context.Context, job *Job) error { +func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error { + defer func() { + if err := recover(); err != nil { + e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1)) + } + }() + + cancelChan := make(chan context.CancelFunc, alertMaxAttempts) + attemptChan := make(chan int, 1) + + // Initialize with first attemptID=1 + attemptChan <- 1 + job.Running = true + + for { + select { + case <-grafanaCtx.Done(): + // In case grafana server context is cancel, let a chance to job processing + // to finish gracefully - by waiting a timeout duration - before forcing its end. + unfinishedWorkTimer := time.NewTimer(unfinishedWorkTimeout) + select { + case <-unfinishedWorkTimer.C: + return e.endJob(grafanaCtx.Err(), cancelChan, job) + case <-attemptChan: + return e.endJob(nil, cancelChan, job) + } + case attemptID, more := <-attemptChan: + if !more { + return e.endJob(nil, cancelChan, job) + } + go e.processJob(attemptID, attemptChan, cancelChan, job) + } + } +} + +func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error { + job.Running = false + close(cancelChan) + for cancelFn := range cancelChan { + cancelFn() + } + return err +} + +func (e *Engine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) { defer func() { if err := recover(); err != nil { e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1)) @@ -104,14 +150,13 @@ func (e *Engine) processJob(grafanaCtx context.Context, job *Job) error { }() alertCtx, cancelFn := context.WithTimeout(context.Background(), alertTimeout) + cancelChan <- cancelFn span := opentracing.StartSpan("alert execution") alertCtx = opentracing.ContextWithSpan(alertCtx, span) - job.Running = true evalContext := NewEvalContext(alertCtx, job.Rule) evalContext.Ctx = alertCtx - done := make(chan struct{}) go func() { defer func() { if err := recover(); err != nil { @@ -122,43 +167,36 @@ func (e *Engine) processJob(grafanaCtx context.Context, job *Job) error { tlog.String("message", "failed to execute alert rule. panic was recovered."), ) span.Finish() - close(done) + close(attemptChan) } }() e.evalHandler.Eval(evalContext) - e.resultHandler.Handle(evalContext) span.SetTag("alertId", evalContext.Rule.Id) span.SetTag("dashboardId", evalContext.Rule.DashboardId) span.SetTag("firing", evalContext.Firing) span.SetTag("nodatapoints", evalContext.NoDataFound) + span.SetTag("attemptID", attemptID) + if evalContext.Error != nil { ext.Error.Set(span, true) span.LogFields( tlog.Error(evalContext.Error), - tlog.String("message", "alerting execution failed"), + tlog.String("message", "alerting execution attempt failed"), ) + if attemptID < alertMaxAttempts { + span.Finish() + e.log.Debug("Job Execution attempt triggered retry", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.Id, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID) + attemptChan <- (attemptID + 1) + return + } } + evalContext.Rule.State = evalContext.GetNewState() + e.resultHandler.Handle(evalContext) span.Finish() - close(done) + e.log.Debug("Job Execution completed", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.Id, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID) + close(attemptChan) }() - - var err error = nil - select { - case <-grafanaCtx.Done(): - select { - case <-time.After(unfinishedWorkTimeout): - cancelFn() - err = grafanaCtx.Err() - case <-done: - } - case <-done: - } - - e.log.Debug("Job Execution completed", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.Id, "name", evalContext.Rule.Name, "firing", evalContext.Firing) - job.Running = false - cancelFn() - return err } diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go new file mode 100644 index 00000000000..64f954c6dd5 --- /dev/null +++ b/pkg/services/alerting/engine_test.go @@ -0,0 +1,118 @@ +package alerting + +import ( + "context" + "errors" + "math" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +type FakeEvalHandler struct { + SuccessCallID int // 0 means never sucess + CallNb int +} + +func NewFakeEvalHandler(successCallID int) *FakeEvalHandler { + return &FakeEvalHandler{ + SuccessCallID: successCallID, + CallNb: 0, + } +} + +func (handler *FakeEvalHandler) Eval(evalContext *EvalContext) { + handler.CallNb++ + if handler.CallNb != handler.SuccessCallID { + evalContext.Error = errors.New("Fake evaluation failure") + } +} + +type FakeResultHandler struct{} + +func (handler *FakeResultHandler) Handle(evalContext *EvalContext) error { + return nil +} + +func TestEngineProcessJob(t *testing.T) { + Convey("Alerting engine job processing", t, func() { + engine := NewEngine() + engine.resultHandler = &FakeResultHandler{} + job := &Job{Running: true, Rule: &Rule{}} + + Convey("Should trigger retry if needed", func() { + + Convey("error + not last attempt -> retry", func() { + engine.evalHandler = NewFakeEvalHandler(0) + + for i := 1; i < alertMaxAttempts; i++ { + attemptChan := make(chan int, 1) + cancelChan := make(chan context.CancelFunc, alertMaxAttempts) + + engine.processJob(i, attemptChan, cancelChan, job) + nextAttemptID, more := <-attemptChan + + So(nextAttemptID, ShouldEqual, i+1) + So(more, ShouldEqual, true) + So(<-cancelChan, ShouldNotBeNil) + } + }) + + Convey("error + last attempt -> no retry", func() { + engine.evalHandler = NewFakeEvalHandler(0) + attemptChan := make(chan int, 1) + cancelChan := make(chan context.CancelFunc, alertMaxAttempts) + + engine.processJob(alertMaxAttempts, attemptChan, cancelChan, job) + nextAttemptID, more := <-attemptChan + + So(nextAttemptID, ShouldEqual, 0) + So(more, ShouldEqual, false) + So(<-cancelChan, ShouldNotBeNil) + }) + + Convey("no error -> no retry", func() { + engine.evalHandler = NewFakeEvalHandler(1) + attemptChan := make(chan int, 1) + cancelChan := make(chan context.CancelFunc, alertMaxAttempts) + + engine.processJob(1, attemptChan, cancelChan, job) + nextAttemptID, more := <-attemptChan + + So(nextAttemptID, ShouldEqual, 0) + So(more, ShouldEqual, false) + So(<-cancelChan, ShouldNotBeNil) + }) + }) + + Convey("Should trigger as many retries as needed", func() { + + Convey("never sucess -> max retries number", func() { + expectedAttempts := alertMaxAttempts + evalHandler := NewFakeEvalHandler(0) + engine.evalHandler = evalHandler + + engine.processJobWithRetry(context.TODO(), job) + So(evalHandler.CallNb, ShouldEqual, expectedAttempts) + }) + + Convey("always sucess -> never retry", func() { + expectedAttempts := 1 + evalHandler := NewFakeEvalHandler(1) + engine.evalHandler = evalHandler + + engine.processJobWithRetry(context.TODO(), job) + So(evalHandler.CallNb, ShouldEqual, expectedAttempts) + }) + + Convey("some errors before sucess -> some retries", func() { + expectedAttempts := int(math.Ceil(float64(alertMaxAttempts) / 2)) + evalHandler := NewFakeEvalHandler(expectedAttempts) + engine.evalHandler = evalHandler + + engine.processJobWithRetry(context.TODO(), job) + So(evalHandler.CallNb, ShouldEqual, expectedAttempts) + }) + }) + }) +} diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index d598203d675..91d0e179a14 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -112,3 +112,34 @@ func (c *EvalContext) GetRuleUrl() (string, error) { return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil } } + +func (c *EvalContext) GetNewState() m.AlertStateType { + if c.Error != nil { + c.log.Error("Alert Rule Result Error", + "ruleId", c.Rule.Id, + "name", c.Rule.Name, + "error", c.Error, + "changing state to", c.Rule.ExecutionErrorState.ToAlertState()) + + if c.Rule.ExecutionErrorState == m.ExecutionErrorKeepState { + return c.PrevAlertState + } + return c.Rule.ExecutionErrorState.ToAlertState() + + } else if c.Firing { + return m.AlertStateAlerting + + } else if c.NoDataFound { + c.log.Info("Alert Rule returned no data", + "ruleId", c.Rule.Id, + "name", c.Rule.Name, + "changing state to", c.Rule.NoDataState.ToAlertState()) + + if c.Rule.NoDataState == m.NoDataKeepState { + return c.PrevAlertState + } + return c.Rule.NoDataState.ToAlertState() + } + + return m.AlertStateOK +} diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go index 019ca1ed01f..709eeee4e5e 100644 --- a/pkg/services/alerting/eval_context_test.go +++ b/pkg/services/alerting/eval_context_test.go @@ -2,6 +2,7 @@ package alerting import ( "context" + "fmt" "testing" "github.com/grafana/grafana/pkg/models" @@ -12,7 +13,7 @@ func TestAlertingEvalContext(t *testing.T) { Convey("Eval context", t, func() { ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) - Convey("Should update alert state", func() { + Convey("Should update alert state when needed", func() { Convey("ok -> alerting", func() { ctx.PrevAlertState = models.AlertStateOK @@ -28,5 +29,71 @@ func TestAlertingEvalContext(t *testing.T) { So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) }) }) + + Convey("Should compute and replace properly new rule state", func() { + dummieError := fmt.Errorf("dummie error") + + Convey("ok -> alerting", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Firing = true + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> error(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + }) + + Convey("pending -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + }) + + Convey("ok -> no_data(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataSetAlerting + ctx.NoDataFound = true + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + }) + + Convey("pending -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = ctx.GetNewState() + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + }) + }) }) } diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index 457e02000fa..aa24efa77cd 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/models" ) type DefaultEvalHandler struct { @@ -66,40 +65,7 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) { context.Firing = firing context.NoDataFound = noDataFound context.EndTime = time.Now() - context.Rule.State = e.getNewState(context) elapsedTime := context.EndTime.Sub(context.StartTime).Nanoseconds() / int64(time.Millisecond) metrics.M_Alerting_Execution_Time.Observe(float64(elapsedTime)) } - -// This should be move into evalContext once its been refactored. (Carl Bergquist) -func (handler *DefaultEvalHandler) getNewState(evalContext *EvalContext) models.AlertStateType { - if evalContext.Error != nil { - handler.log.Error("Alert Rule Result Error", - "ruleId", evalContext.Rule.Id, - "name", evalContext.Rule.Name, - "error", evalContext.Error, - "changing state to", evalContext.Rule.ExecutionErrorState.ToAlertState()) - - if evalContext.Rule.ExecutionErrorState == models.ExecutionErrorKeepState { - return evalContext.PrevAlertState - } else { - return evalContext.Rule.ExecutionErrorState.ToAlertState() - } - } else if evalContext.Firing { - return models.AlertStateAlerting - } else if evalContext.NoDataFound { - handler.log.Info("Alert Rule returned no data", - "ruleId", evalContext.Rule.Id, - "name", evalContext.Rule.Name, - "changing state to", evalContext.Rule.NoDataState.ToAlertState()) - - if evalContext.Rule.NoDataState == models.NoDataKeepState { - return evalContext.PrevAlertState - } else { - return evalContext.Rule.NoDataState.ToAlertState() - } - } - - return models.AlertStateOK -} diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go index c942e24818f..a7c1f1e67fa 100644 --- a/pkg/services/alerting/eval_handler_test.go +++ b/pkg/services/alerting/eval_handler_test.go @@ -2,10 +2,8 @@ package alerting import ( "context" - "fmt" "testing" - "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" ) @@ -203,73 +201,5 @@ func TestAlertingEvaluationHandler(t *testing.T) { handler.Eval(context) So(context.NoDataFound, ShouldBeTrue) }) - - Convey("EvalHandler can replace alert state based for errors and no_data", func() { - ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) - dummieError := fmt.Errorf("dummie error") - Convey("Should update alert state", func() { - - Convey("ok -> alerting", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Firing = true - - So(handler.getNewState(ctx), ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> error(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - }) - - Convey("pending -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - }) - - Convey("ok -> no_data(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataSetAlerting - ctx.NoDataFound = true - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - }) - - Convey("pending -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = handler.getNewState(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - }) - }) - }) }) } diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index a609824cbc8..edd872b8fce 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -11,76 +11,93 @@ import ( m "github.com/grafana/grafana/pkg/models" ) +// DashAlertExtractor extracts alerts from the dashboard json type DashAlertExtractor struct { Dash *m.Dashboard - OrgId int64 + OrgID int64 log log.Logger } -func NewDashAlertExtractor(dash *m.Dashboard, orgId int64) *DashAlertExtractor { +// NewDashAlertExtractor returns a new DashAlertExtractor +func NewDashAlertExtractor(dash *m.Dashboard, orgID int64) *DashAlertExtractor { return &DashAlertExtractor{ Dash: dash, - OrgId: orgId, + OrgID: orgID, log: log.New("alerting.extractor"), } } -func (e *DashAlertExtractor) lookupDatasourceId(dsName string) (*m.DataSource, error) { +func (e *DashAlertExtractor) lookupDatasourceID(dsName string) (*m.DataSource, error) { if dsName == "" { - query := &m.GetDataSourcesQuery{OrgId: e.OrgId} + query := &m.GetDataSourcesQuery{OrgId: e.OrgID} if err := bus.Dispatch(query); err != nil { return nil, err - } else { - for _, ds := range query.Result { - if ds.IsDefault { - return ds, nil - } + } + + for _, ds := range query.Result { + if ds.IsDefault { + return ds, nil } } } else { - query := &m.GetDataSourceByNameQuery{Name: dsName, OrgId: e.OrgId} + query := &m.GetDataSourceByNameQuery{Name: dsName, OrgId: e.OrgID} if err := bus.Dispatch(query); err != nil { return nil, err - } else { - return query.Result, nil } + + return query.Result, nil } return nil, errors.New("Could not find datasource id for " + dsName) } -func findPanelQueryByRefId(panel *simplejson.Json, refId string) *simplejson.Json { +func findPanelQueryByRefID(panel *simplejson.Json, refID string) *simplejson.Json { for _, targetsObj := range panel.Get("targets").MustArray() { target := simplejson.NewFromAny(targetsObj) - if target.Get("refId").MustString() == refId { + if target.Get("refId").MustString() == refID { return target } } return nil } -func copyJson(in *simplejson.Json) (*simplejson.Json, error) { - rawJson, err := in.MarshalJSON() +func copyJSON(in *simplejson.Json) (*simplejson.Json, error) { + rawJSON, err := in.MarshalJSON() if err != nil { return nil, err } - return simplejson.NewJson(rawJson) + return simplejson.NewJson(rawJSON) } -func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) ([]*m.Alert, error) { +func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, validateAlertFunc func(*m.Alert) bool) ([]*m.Alert, error) { alerts := make([]*m.Alert, 0) for _, panelObj := range jsonWithPanels.Get("panels").MustArray() { panel := simplejson.NewFromAny(panelObj) + + collapsedJSON, collapsed := panel.CheckGet("collapsed") + // check if the panel is collapsed + if collapsed && collapsedJSON.MustBool() { + + // extract alerts from sub panels for collapsed panels + als, err := e.getAlertFromPanels(panel, validateAlertFunc) + if err != nil { + return nil, err + } + + alerts = append(alerts, als...) + continue + } + jsonAlert, hasAlert := panel.CheckGet("alert") if !hasAlert { continue } - panelId, err := panel.Get("id").Int64() + panelID, err := panel.Get("id").Int64() if err != nil { return nil, fmt.Errorf("panel id is required. err %v", err) } @@ -98,8 +115,8 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) alert := &m.Alert{ DashboardId: e.Dash.Id, - OrgId: e.OrgId, - PanelId: panelId, + OrgId: e.OrgID, + PanelId: panelID, Id: jsonAlert.Get("id").MustInt64(), Name: jsonAlert.Get("name").MustString(), Handler: jsonAlert.Get("handler").MustInt64(), @@ -111,11 +128,11 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) jsonCondition := simplejson.NewFromAny(condition) jsonQuery := jsonCondition.Get("query") - queryRefId := jsonQuery.Get("params").MustArray()[0].(string) - panelQuery := findPanelQueryByRefId(panel, queryRefId) + queryRefID := jsonQuery.Get("params").MustArray()[0].(string) + panelQuery := findPanelQueryByRefID(panel, queryRefID) if panelQuery == nil { - reason := fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelId, queryRefId) + reason := fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelId, queryRefID) return nil, ValidationError{Reason: reason} } @@ -126,12 +143,13 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) dsName = panel.Get("datasource").MustString() } - if datasource, err := e.lookupDatasourceId(dsName); err != nil { + datasource, err := e.lookupDatasourceID(dsName) + if err != nil { return nil, err - } else { - jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id) } + jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id) + if interval, err := panel.Get("interval").String(); err == nil { panelQuery.Set("interval", interval) } @@ -143,20 +161,32 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json) // validate _, err = NewRuleFromDBAlert(alert) - if err == nil && alert.ValidToSave() { - alerts = append(alerts, alert) - } else { + if err != nil { return nil, err } + + if !validateAlertFunc(alert) { + e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId) + return nil, m.ErrDashboardContainsInvalidAlertData + } + + alerts = append(alerts, alert) } return alerts, nil } -func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { - e.log.Debug("GetAlerts") +func validateAlertRule(alert *m.Alert) bool { + return alert.ValidToSave() +} - dashboardJson, err := copyJson(e.Dash.Data) +// GetAlerts extracts alerts from the dashboard json and does full validation on the alert json data +func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { + return e.extractAlerts(validateAlertRule) +} + +func (e *DashAlertExtractor) extractAlerts(validateFunc func(alert *m.Alert) bool) ([]*m.Alert, error) { + dashboardJSON, err := copyJSON(e.Dash.Data) if err != nil { return nil, err } @@ -165,11 +195,11 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { // We extract alerts from rows to be backwards compatible // with the old dashboard json model. - rows := dashboardJson.Get("rows").MustArray() + rows := dashboardJSON.Get("rows").MustArray() if len(rows) > 0 { for _, rowObj := range rows { row := simplejson.NewFromAny(rowObj) - a, err := e.GetAlertFromPanels(row) + a, err := e.getAlertFromPanels(row, validateFunc) if err != nil { return nil, err } @@ -177,7 +207,7 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { alerts = append(alerts, a...) } } else { - a, err := e.GetAlertFromPanels(dashboardJson) + a, err := e.getAlertFromPanels(dashboardJSON, validateFunc) if err != nil { return nil, err } @@ -188,3 +218,10 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts)) return alerts, nil } + +// ValidateAlerts validates alerts in the dashboard json but does not require a valid dashboard id +// in the first validation pass +func (e *DashAlertExtractor) ValidateAlerts() error { + _, err := e.extractAlerts(func(alert *m.Alert) bool { return alert.OrgId != 0 && alert.PanelId != 0 }) + return err +} diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 71f3026025d..861e9b9cbfc 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -22,6 +22,7 @@ func TestAlertRuleExtraction(t *testing.T) { defaultDs := &m.DataSource{Id: 12, OrgId: 1, Name: "I am default", IsDefault: true} graphite2Ds := &m.DataSource{Id: 15, OrgId: 1, Name: "graphite2"} influxDBDs := &m.DataSource{Id: 16, OrgId: 1, Name: "InfluxDB"} + prom := &m.DataSource{Id: 17, OrgId: 1, Name: "Prometheus"} bus.AddHandler("test", func(query *m.GetDataSourcesQuery) error { query.Result = []*m.DataSource{defaultDs, graphite2Ds} @@ -38,6 +39,10 @@ func TestAlertRuleExtraction(t *testing.T) { if query.Name == influxDBDs.Name { query.Result = influxDBDs } + if query.Name == prom.Name { + query.Result = prom + } + return nil }) @@ -150,6 +155,22 @@ func TestAlertRuleExtraction(t *testing.T) { }) }) + Convey("Panel with id set to zero should return error", func() { + panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json") + So(err, ShouldBeNil) + + dashJson, err := simplejson.NewJson([]byte(panelWithIdZero)) + So(err, ShouldBeNil) + dash := m.NewDashboardFromJson(dashJson) + extractor := NewDashAlertExtractor(dash, 1) + + _, err = extractor.GetAlerts() + + Convey("panel with id 0 should return error", func() { + So(err, ShouldNotBeNil) + }) + }) + Convey("Parse alerts from dashboard without rows", func() { json, err := ioutil.ReadFile("./test-data/v5-dashboard.json") So(err, ShouldBeNil) @@ -198,5 +219,47 @@ func TestAlertRuleExtraction(t *testing.T) { } }) }) + + Convey("Should be able to extract collapsed panels", func() { + json, err := ioutil.ReadFile("./test-data/collapsed-panels.json") + So(err, ShouldBeNil) + + dashJson, err := simplejson.NewJson(json) + So(err, ShouldBeNil) + + dash := m.NewDashboardFromJson(dashJson) + extractor := NewDashAlertExtractor(dash, 1) + + alerts, err := extractor.GetAlerts() + + Convey("Get rules without error", func() { + So(err, ShouldBeNil) + }) + + Convey("should be able to extract collapsed alerts", func() { + So(len(alerts), ShouldEqual, 4) + }) + }) + + Convey("Parse and validate dashboard without id and containing an alert", func() { + json, err := ioutil.ReadFile("./test-data/dash-without-id.json") + So(err, ShouldBeNil) + + dashJSON, err := simplejson.NewJson(json) + So(err, ShouldBeNil) + dash := m.NewDashboardFromJson(dashJSON) + extractor := NewDashAlertExtractor(dash, 1) + + err = extractor.ValidateAlerts() + + Convey("Should validate without error", func() { + So(err, ShouldBeNil) + }) + + Convey("Should fail on save", func() { + _, err := extractor.GetAlerts() + So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData) + }) + }) }) } diff --git a/pkg/services/alerting/notifiers/alertmanager.go b/pkg/services/alerting/notifiers/alertmanager.go index 08f8e8be29c..d449167de13 100644 --- a/pkg/services/alerting/notifiers/alertmanager.go +++ b/pkg/services/alerting/notifiers/alertmanager.go @@ -46,25 +46,49 @@ type AlertmanagerNotifier struct { } func (this *AlertmanagerNotifier) ShouldNotify(evalContext *alerting.EvalContext) bool { + this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState) + + // Do not notify when we become OK for the first time. + if (evalContext.PrevAlertState == m.AlertStatePending) && (evalContext.Rule.State == m.AlertStateOK) { + return false + } + // Notify on Alerting -> OK to resolve before alertmanager timeout. + if (evalContext.PrevAlertState == m.AlertStateAlerting) && (evalContext.Rule.State == m.AlertStateOK) { + return true + } return evalContext.Rule.State == m.AlertStateAlerting } -func (this *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error { +func (this *AlertmanagerNotifier) createAlert(evalContext *alerting.EvalContext, match *alerting.EvalMatch, ruleUrl string) *simplejson.Json { + alertJSON := simplejson.New() + alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339)) + if evalContext.Rule.State == m.AlertStateOK { + alertJSON.Set("endsAt", time.Now().UTC().Format(time.RFC3339)) + } + alertJSON.Set("generatorURL", ruleUrl) - alerts := make([]interface{}, 0) - for _, match := range evalContext.EvalMatches { - alertJSON := simplejson.New() - alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339)) - - if ruleUrl, err := evalContext.GetRuleUrl(); err == nil { - alertJSON.Set("generatorURL", ruleUrl) + // Annotations (summary and description are very commonly used). + alertJSON.SetPath([]string{"annotations", "summary"}, evalContext.Rule.Name) + description := "" + if evalContext.Rule.Message != "" { + description += evalContext.Rule.Message + } + if evalContext.Error != nil { + if description != "" { + description += "\n" } + description += "Error: " + evalContext.Error.Error() + } + if description != "" { + alertJSON.SetPath([]string{"annotations", "description"}, description) + } + if evalContext.ImagePublicUrl != "" { + alertJSON.SetPath([]string{"annotations", "image"}, evalContext.ImagePublicUrl) + } - if evalContext.Rule.Message != "" { - alertJSON.SetPath([]string{"annotations", "description"}, evalContext.Rule.Message) - } - - tags := make(map[string]string) + // Labels (from metrics tags + mandatory alertname). + tags := make(map[string]string) + if match != nil { if len(match.Tags) == 0 { tags["metric"] = match.Metric } else { @@ -72,10 +96,32 @@ func (this *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) erro tags[k] = v } } - tags["alertname"] = evalContext.Rule.Name - alertJSON.Set("labels", tags) + } + tags["alertname"] = evalContext.Rule.Name + alertJSON.Set("labels", tags) + return alertJSON +} - alerts = append(alerts, alertJSON) +func (this *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Sending Alertmanager alert", "ruleId", evalContext.Rule.Id, "notification", this.Name) + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + + // Send one alert per matching series. + alerts := make([]interface{}, 0) + for _, match := range evalContext.EvalMatches { + alert := this.createAlert(evalContext, match, ruleUrl) + alerts = append(alerts, alert) + } + + // This happens on ExecutionError or NoData + if len(alerts) == 0 { + alert := this.createAlert(evalContext, nil, ruleUrl) + alerts = append(alerts, alert) } bodyJSON := simplejson.NewFromAny(alerts) diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index 601f8fc24b1..51676efdfd5 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -15,7 +15,11 @@ type NotifierBase struct { } func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase { - uploadImage := model.Get("uploadImage").MustBool(false) + uploadImage := true + value, exist := model.CheckGet("uploadImage") + if exist { + uploadImage = value.MustBool() + } return NotifierBase{ Id: id, @@ -27,15 +31,21 @@ func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model } func defaultShouldNotify(context *alerting.EvalContext) bool { + // Only notify on state change. if context.PrevAlertState == context.Rule.State { return false } + // Do not notify when we become OK for the first time. if (context.PrevAlertState == m.AlertStatePending) && (context.Rule.State == m.AlertStateOK) { return false } return true } +func (n *NotifierBase) ShouldNotify(context *alerting.EvalContext) bool { + return defaultShouldNotify(context) +} + func (n *NotifierBase) GetType() string { return n.Type } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 4225e203a3d..b7142d144cc 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" . "github.com/smartystreets/goconvey/convey" @@ -11,6 +12,29 @@ import ( func TestBaseNotifier(t *testing.T) { Convey("Base notifier tests", t, func() { + Convey("default constructor for notifiers", func() { + bJson := simplejson.New() + + Convey("can parse false value", func() { + bJson.Set("uploadImage", false) + + base := NewNotifierBase(1, false, "name", "email", bJson) + So(base.UploadImage, ShouldBeFalse) + }) + + Convey("can parse true value", func() { + bJson.Set("uploadImage", true) + + base := NewNotifierBase(1, false, "name", "email", bJson) + So(base.UploadImage, ShouldBeTrue) + }) + + Convey("default value should be true for backwards compatibility", func() { + base := NewNotifierBase(1, false, "name", "email", bJson) + So(base.UploadImage, ShouldBeTrue) + }) + }) + Convey("should notify", func() { Convey("pending -> ok", func() { context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index c2029b1173c..e32b9d34f91 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -38,10 +38,6 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error) }, nil } -func (this *DingDingNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - type DingDingNotifier struct { NotifierBase Url string diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index 095f7c7156a..562ffbe1269 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -58,10 +58,6 @@ func NewEmailNotifier(model *m.AlertNotification) (alerting.Notifier, error) { }, nil } -func (this *EmailNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *EmailNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending alert notification to", "addresses", this.Addresses) diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index b65f25b1422..f1f63d42a04 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -75,10 +75,6 @@ type HipChatNotifier struct { log log.Logger } -func (this *HipChatNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go index e885d44405d..92f6489106b 100644 --- a/pkg/services/alerting/notifiers/kafka.go +++ b/pkg/services/alerting/notifiers/kafka.go @@ -57,10 +57,6 @@ type KafkaNotifier struct { log log.Logger } -func (this *KafkaNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { state := evalContext.Rule.State diff --git a/pkg/services/alerting/notifiers/line.go b/pkg/services/alerting/notifiers/line.go index bc0b0c984a4..4fbaa2d543e 100644 --- a/pkg/services/alerting/notifiers/line.go +++ b/pkg/services/alerting/notifiers/line.go @@ -51,10 +51,6 @@ type LineNotifier struct { log log.Logger } -func (this *LineNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *LineNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing line notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 82be9d1df4e..5d8b15160c4 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -72,10 +72,6 @@ type OpsGenieNotifier struct { log log.Logger } -func (this *OpsGenieNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error { var err error @@ -99,11 +95,16 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err return err } + customData := "Triggered metrics:\n\n" + for _, evt := range evalContext.EvalMatches { + customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) + } + bodyJSON := simplejson.New() bodyJSON.Set("message", evalContext.Rule.Name) bodyJSON.Set("source", "Grafana") bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) - bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message)) + bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message, customData)) details := simplejson.New() details.Set("url", ruleUrl) diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index c4067abec3b..58484051432 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -1,7 +1,9 @@ package notifiers import ( + "os" "strconv" + "time" "fmt" @@ -38,7 +40,7 @@ func init() { } var ( - pagerdutyEventApiUrl string = "https://events.pagerduty.com/generic/2010-04-15/create_event.json" + pagerdutyEventApiUrl string = "https://events.pagerduty.com/v2/enqueue" ) func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) { @@ -63,10 +65,6 @@ type PagerdutyNotifier struct { log log.Logger } -func (this *PagerdutyNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { if evalContext.Rule.State == m.AlertStateOK && !this.AutoResolve { @@ -85,28 +83,41 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Notifying Pagerduty", "event_type", eventType) + payloadJSON := simplejson.New() + payloadJSON.Set("summary", evalContext.Rule.Name+" - "+evalContext.Rule.Message) + if hostname, err := os.Hostname(); err == nil { + payloadJSON.Set("source", hostname) + } + payloadJSON.Set("severity", "critical") + payloadJSON.Set("timestamp", time.Now()) + payloadJSON.Set("component", "Grafana") + payloadJSON.Set("custom_details", customData) + bodyJSON := simplejson.New() - bodyJSON.Set("service_key", this.Key) - bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) - bodyJSON.Set("client", "Grafana") - bodyJSON.Set("details", customData) - bodyJSON.Set("event_type", eventType) - bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) + bodyJSON.Set("routing_key", this.Key) + bodyJSON.Set("event_action", eventType) + bodyJSON.Set("dedup_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) + bodyJSON.Set("payload", payloadJSON) ruleUrl, err := evalContext.GetRuleUrl() if err != nil { this.log.Error("Failed get rule link", "error", err) return err } + links := make([]interface{}, 1) + linkJSON := simplejson.New() + linkJSON.Set("href", ruleUrl) bodyJSON.Set("client_url", ruleUrl) + bodyJSON.Set("client", "Grafana") + links[0] = linkJSON + bodyJSON.Set("links", links) if evalContext.ImagePublicUrl != "" { contexts := make([]interface{}, 1) imageJSON := simplejson.New() - imageJSON.Set("type", "image") imageJSON.Set("src", evalContext.ImagePublicUrl) contexts[0] = imageJSON - bodyJSON.Set("contexts", contexts) + bodyJSON.Set("images", contexts) } body, _ := bodyJSON.MarshalJSON() @@ -115,6 +126,9 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { Url: pagerdutyEventApiUrl, Body: string(body), HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/json", + }, } if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go index 067c02a4a5d..cbe9e16801a 100644 --- a/pkg/services/alerting/notifiers/pushover.go +++ b/pkg/services/alerting/notifiers/pushover.go @@ -123,10 +123,6 @@ type PushoverNotifier struct { log log.Logger } -func (this *PushoverNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { ruleUrl, err := evalContext.GetRuleUrl() if err != nil { diff --git a/pkg/services/alerting/notifiers/sensu.go b/pkg/services/alerting/notifiers/sensu.go index 7a34d51b493..9f77801d458 100644 --- a/pkg/services/alerting/notifiers/sensu.go +++ b/pkg/services/alerting/notifiers/sensu.go @@ -71,10 +71,6 @@ type SensuNotifier struct { log log.Logger } -func (this *SensuNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending sensu result") diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index c5bb9344e30..e051a71740a 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -98,10 +98,6 @@ type SlackNotifier struct { log log.Logger } -func (this *SlackNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing slack notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go index 605b2742325..9a9e93dbc47 100644 --- a/pkg/services/alerting/notifiers/teams.go +++ b/pkg/services/alerting/notifiers/teams.go @@ -47,10 +47,6 @@ type TeamsNotifier struct { log log.Logger } -func (this *TeamsNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing teams notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) @@ -82,6 +78,8 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { message := this.Mention if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. message += " " + evalContext.Rule.Message + } else { + message += " " // summary must not be empty } body := map[string]interface{}{ diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 0ca1ad3dfe0..5cbdad60906 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -12,6 +12,10 @@ import ( "os" ) +const ( + captionLengthLimit = 200 +) + var ( telegramApiUrl string = "https://api.telegram.org/bot%s/%s" ) @@ -82,88 +86,81 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error) } func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, sendImageInline bool) *m.SendWebhookSync { - var imageFile *os.File - var err error - if sendImageInline { - imageFile, err = os.Open(evalContext.ImageOnDiskPath) - defer imageFile.Close() - if err != nil { - sendImageInline = false // fall back to text message + cmd, err := this.buildMessageInlineImage(evalContext) + if err == nil { + return cmd + } else { + this.log.Error("Could not generate Telegram message with inline image.", "err", err) } } - message := "" + return this.buildMessageLinkedImage(evalContext) +} - if sendImageInline { - // Telegram's API does not allow HTML formatting for image captions. - message = fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message) - } else { - message = fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message) - } +func (this *TelegramNotifier) buildMessageLinkedImage(evalContext *alerting.EvalContext) *m.SendWebhookSync { + message := fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message) ruleUrl, err := evalContext.GetRuleUrl() if err == nil { message = message + fmt.Sprintf("URL: %s\n", ruleUrl) } - if !sendImageInline { - // only attach this if we are not sending it inline. - if evalContext.ImagePublicUrl != "" { - message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl) - } - } - - metrics := "" - fieldLimitCount := 4 - for index, evt := range evalContext.EvalMatches { - metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value) - if index > fieldLimitCount { - break - } + if evalContext.ImagePublicUrl != "" { + message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl) } + metrics := generateMetricsMessage(evalContext) if metrics != "" { - if sendImageInline { - // Telegram's API does not allow HTML formatting for image captions. - message = message + fmt.Sprintf("\nMetrics:%s", metrics) - } else { - message = message + fmt.Sprintf("\nMetrics:%s", metrics) - } + message = message + fmt.Sprintf("\nMetrics:%s", metrics) } - var body bytes.Buffer + cmd := this.generateTelegramCmd(message, "text", "sendMessage", func(w *multipart.Writer) { + fw, _ := w.CreateFormField("parse_mode") + fw.Write([]byte("html")) + }) + return cmd +} +func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalContext) (*m.SendWebhookSync, error) { + var imageFile *os.File + var err error + + imageFile, err = os.Open(evalContext.ImageOnDiskPath) + defer imageFile.Close() + if err != nil { + return nil, err + } + + ruleUrl, err := evalContext.GetRuleUrl() + + metrics := generateMetricsMessage(evalContext) + message := generateImageCaption(evalContext, ruleUrl, metrics) + + cmd := this.generateTelegramCmd(message, "caption", "sendPhoto", func(w *multipart.Writer) { + fw, _ := w.CreateFormFile("photo", evalContext.ImageOnDiskPath) + io.Copy(fw, imageFile) + }) + return cmd, nil +} + +func (this *TelegramNotifier) generateTelegramCmd(message string, messageField string, apiAction string, extraConf func(writer *multipart.Writer)) *m.SendWebhookSync { + var body bytes.Buffer w := multipart.NewWriter(&body) + fw, _ := w.CreateFormField("chat_id") fw.Write([]byte(this.ChatID)) - if sendImageInline { - fw, _ = w.CreateFormField("caption") - fw.Write([]byte(message)) + fw, _ = w.CreateFormField(messageField) + fw.Write([]byte(message)) - fw, _ = w.CreateFormFile("photo", evalContext.ImageOnDiskPath) - io.Copy(fw, imageFile) - } else { - fw, _ = w.CreateFormField("text") - fw.Write([]byte(message)) - - fw, _ = w.CreateFormField("parse_mode") - fw.Write([]byte("html")) - } + extraConf(w) w.Close() - apiMethod := "" - if sendImageInline { - this.log.Info("Sending telegram image notification", "photo", evalContext.ImageOnDiskPath, "chat_id", this.ChatID, "bot_token", this.BotToken) - apiMethod = "sendPhoto" - } else { - this.log.Info("Sending telegram text notification", "chat_id", this.ChatID, "bot_token", this.BotToken) - apiMethod = "sendMessage" - } + this.log.Info("Sending telegram notification", "chat_id", this.ChatID, "bot_token", this.BotToken, "apiAction", apiAction) + url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiAction) - url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiMethod) cmd := &m.SendWebhookSync{ Url: url, Body: body.String(), @@ -175,8 +172,49 @@ func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, se return cmd } -func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) +func generateMetricsMessage(evalContext *alerting.EvalContext) string { + metrics := "" + fieldLimitCount := 4 + for index, evt := range evalContext.EvalMatches { + metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value) + if index > fieldLimitCount { + break + } + } + return metrics +} + +func generateImageCaption(evalContext *alerting.EvalContext, ruleUrl string, metrics string) string { + message := evalContext.GetNotificationTitle() + + if len(evalContext.Rule.Message) > 0 { + message = fmt.Sprintf("%s\nMessage: %s", message, evalContext.Rule.Message) + } + + if len(message) > captionLengthLimit { + message = message[0:captionLengthLimit] + + } + + if len(ruleUrl) > 0 { + urlLine := fmt.Sprintf("\nURL: %s", ruleUrl) + message = appendIfPossible(message, urlLine, captionLengthLimit) + } + + if metrics != "" { + metricsLines := fmt.Sprintf("\n\nMetrics:%s", metrics) + message = appendIfPossible(message, metricsLines, captionLengthLimit) + } + + return message +} + +func appendIfPossible(message string, extra string, sizeLimit int) string { + if len(extra)+len(message) <= sizeLimit { + return message + extra + } + log.Debug("Line too long for image caption.", "value", extra) + return message } func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go index 3e8066e273b..05be787dced 100644 --- a/pkg/services/alerting/notifiers/telegram_test.go +++ b/pkg/services/alerting/notifiers/telegram_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" . "github.com/smartystreets/goconvey/convey" ) @@ -50,6 +51,71 @@ func TestTelegramNotifier(t *testing.T) { So(telegramNotifier.ChatID, ShouldEqual, "-1234567890") }) + Convey("generateCaption should generate a message with all pertinent details", func() { + evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ + Name: "This is an alarm", + Message: "Some kind of message.", + State: m.AlertStateOK, + }) + + caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "") + So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(caption, ShouldContainSubstring, "Some kind of message.") + So(caption, ShouldContainSubstring, "[OK] This is an alarm") + So(caption, ShouldContainSubstring, "http://grafa.url/abcdef") + }) + + Convey("When generating a message", func() { + + Convey("URL should be skipped if it's too long", func() { + evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ + Name: "This is an alarm", + Message: "Some kind of message.", + State: m.AlertStateOK, + }) + + caption := generateImageCaption(evalContext, + "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "foo bar") + So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(caption, ShouldContainSubstring, "Some kind of message.") + So(caption, ShouldContainSubstring, "[OK] This is an alarm") + So(caption, ShouldContainSubstring, "foo bar") + So(caption, ShouldNotContainSubstring, "http") + }) + + Convey("Message should be trimmed if it's too long", func() { + evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ + Name: "This is an alarm", + Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.", + State: m.AlertStateOK, + }) + + caption := generateImageCaption(evalContext, + "http://grafa.url/foo", + "") + So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(caption, ShouldContainSubstring, "[OK] This is an alarm") + So(caption, ShouldNotContainSubstring, "http") + So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ") + }) + + Convey("Metrics should be skipped if they dont fit", func() { + evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ + Name: "This is an alarm", + Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ", + State: m.AlertStateOK, + }) + + caption := generateImageCaption(evalContext, + "http://grafa.url/foo", + "foo bar long song") + So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(caption, ShouldContainSubstring, "[OK] This is an alarm") + So(caption, ShouldNotContainSubstring, "http") + So(caption, ShouldNotContainSubstring, "foo bar") + }) + }) }) }) } diff --git a/pkg/services/alerting/notifiers/threema.go b/pkg/services/alerting/notifiers/threema.go index b8455dcfbfd..e4ffffc9108 100644 --- a/pkg/services/alerting/notifiers/threema.go +++ b/pkg/services/alerting/notifiers/threema.go @@ -114,10 +114,6 @@ func NewThreemaNotifier(model *m.AlertNotification) (alerting.Notifier, error) { }, nil } -func (this *ThreemaNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error { notifier.log.Info("Sending alert notification from", "threema_id", notifier.GatewayID) notifier.log.Info("Sending alert notification to", "threema_id", notifier.RecipientID) diff --git a/pkg/services/alerting/notifiers/victorops.go b/pkg/services/alerting/notifiers/victorops.go index a2b770dfd02..4b4db553cde 100644 --- a/pkg/services/alerting/notifiers/victorops.go +++ b/pkg/services/alerting/notifiers/victorops.go @@ -68,10 +68,6 @@ type VictoropsNotifier struct { log log.Logger } -func (this *VictoropsNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - // Notify sends notification to Victorops via POST to URL endpoint func (this *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Executing victorops notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go index d2d6ec636b7..4c97ed2b75e 100644 --- a/pkg/services/alerting/notifiers/webhook.go +++ b/pkg/services/alerting/notifiers/webhook.go @@ -65,10 +65,6 @@ type WebhookNotifier struct { log log.Logger } -func (this *WebhookNotifier) ShouldNotify(context *alerting.EvalContext) bool { - return defaultShouldNotify(context) -} - func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Info("Sending webhook") diff --git a/pkg/services/alerting/test-data/collapsed-panels.json b/pkg/services/alerting/test-data/collapsed-panels.json new file mode 100644 index 00000000000..29109788955 --- /dev/null +++ b/pkg/services/alerting/test-data/collapsed-panels.json @@ -0,0 +1,597 @@ +{ + "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": 127, + "links": [], + "panels": [ + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 9, + "title": "Row title", + "type": "row" + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 200 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "frequency": "10s", + "handler": 1, + "name": "Panel Title alert", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 10, + "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, + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 200 + } + ], + "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 + } + ] + }, + { + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 14, + "limit": 10, + "links": [], + "onlyAlertsOnDashboard": true, + "show": "current", + "sortOrder": 1, + "stateFilter": [], + "title": "Panel Title", + "type": "alertlist" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 6, + "panels": [ + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 200 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "frequency": "10s", + "handler": 1, + "name": "Panel 2 alert", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 11 + }, + "id": 11, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "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, + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 200 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel 2", + "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 + } + ] + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 200 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "frequency": "10s", + "handler": 1, + "name": "Panel 4 alert", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 11 + }, + "id": 15, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "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, + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 200 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel 4", + "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 + } + ] + } + ], + "title": "Row title", + "type": "row" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 4, + "title": "Row title", + "type": "row" + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 200 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "frequency": "10s", + "handler": 1, + "name": "Panel 3 alert", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "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, + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 200 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel 3", + "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 + } + ] + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "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": "New dashboard Copy", + "uid": "6v5pg36zk", + "version": 17 +} diff --git a/pkg/services/alerting/test-data/dash-without-id.json b/pkg/services/alerting/test-data/dash-without-id.json new file mode 100644 index 00000000000..e0a212695d8 --- /dev/null +++ b/pkg/services/alerting/test-data/dash-without-id.json @@ -0,0 +1,281 @@ +{ + "title": "Influxdb", + "tags": [ + "apa" + ], + "style": "dark", + "timezone": "browser", + "editable": true, + "hideControls": false, + "sharedCrosshair": false, + "rows": [ + { + "collapse": false, + "editable": true, + "height": "450px", + "panels": [ + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 10 + ], + "type": "gt" + }, + "query": { + "params": [ + "B", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "frequency": "3s", + "handler": 1, + "name": "Influxdb", + "noDataState": "no_data", + "notifications": [ + { + "id": 6 + } + ] + }, + "alerting": {}, + "aliasColors": { + "logins.count.count": "#890F02" + }, + "bars": false, + "datasource": "InfluxDB", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "id": 1, + "interval": ">10s", + "isNew": true, + "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": [], + "span": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "datacenter" + ], + "type": "tag" + }, + { + "params": [ + "none" + ], + "type": "fill" + } + ], + "hide": false, + "measurement": "logins.count", + "policy": "default", + "query": "SELECT 8 * count(\"value\") FROM \"logins.count\" WHERE $timeFilter GROUP BY time($interval), \"datacenter\" fill(none)", + "rawQuery": true, + "refId": "B", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "count" + } + ] + ], + "tags": [] + }, + { + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "hide": true, + "measurement": "cpu", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "mean" + } + ], + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "sum" + } + ] + ], + "tags": [] + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 10 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "tooltip": { + "msResolution": false, + "ordering": "alphabetical", + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "editable": true, + "error": false, + "id": 2, + "isNew": true, + "limit": 10, + "links": [], + "show": "current", + "span": 2, + "stateFilter": [ + "alerting" + ], + "title": "Alert status", + "type": "alertlist" + } + ], + "title": "Row" + } + ], + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "templating": { + "list": [] + }, + "annotations": { + "list": [] + }, + "schemaVersion": 13, + "version": 120, + "links": [], + "gnetId": null + } diff --git a/pkg/services/alerting/test-data/influxdb-alert.json b/pkg/services/alerting/test-data/influxdb-alert.json index 79ca355c5a1..fd6feb31a47 100644 --- a/pkg/services/alerting/test-data/influxdb-alert.json +++ b/pkg/services/alerting/test-data/influxdb-alert.json @@ -279,4 +279,4 @@ "version": 120, "links": [], "gnetId": null - } \ No newline at end of file + } diff --git a/pkg/services/alerting/test-data/panel-with-id-0.json b/pkg/services/alerting/test-data/panel-with-id-0.json new file mode 100644 index 00000000000..d1f314a4f55 --- /dev/null +++ b/pkg/services/alerting/test-data/panel-with-id-0.json @@ -0,0 +1,63 @@ +{ + "id": 57, + "title": "Graphite 4", + "originalTitle": "Graphite 4", + "tags": ["graphite"], + "rows": [ + { + "panels": [ + { + "title": "Active desktop users", + "id": 0, + "editable": true, + "type": "graph", + "targets": [ + { + "refId": "A", + "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)" + } + ], + "datasource": null, + "alert": { + "name": "name1", + "message": "desc1", + "handler": 1, + "frequency": "60s", + "conditions": [ + { + "type": "query", + "query": {"params": ["A", "5m", "now"]}, + "reducer": {"type": "avg", "params": []}, + "evaluator": {"type": ">", "params": [100]} + } + ] + } + }, + { + "title": "Active mobile users", + "id": 4, + "targets": [ + {"refId": "A", "target": ""}, + {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"} + ], + "datasource": "graphite2", + "alert": { + "name": "name2", + "message": "desc2", + "handler": 0, + "frequency": "60s", + "severity": "warning", + "conditions": [ + { + "type": "query", + "query": {"params": ["B", "5m", "now"]}, + "reducer": {"type": "avg", "params": []}, + "evaluator": {"type": ">", "params": [100]} + } + ] + } + } + ] + } +] + } diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index e3aa95e0ede..88418bff14e 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -53,6 +53,7 @@ func testAlertRule(rule *Rule) *EvalContext { context.IsTestRun = true handler.Eval(context) + context.Rule.State = context.GetNewState() return context } diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index f9dcfce51b7..5e9efeea3b0 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -83,11 +83,21 @@ func (service *CleanUpService) cleanUpTmpFiles() { } func (service *CleanUpService) deleteExpiredSnapshots() { - bus.Dispatch(&m.DeleteExpiredSnapshotsCommand{}) + cmd := m.DeleteExpiredSnapshotsCommand{} + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Failed to delete expired snapshots", "error", err.Error()) + } else { + service.log.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows) + } } func (service *CleanUpService) deleteExpiredDashboardVersions() { - bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) + cmd := m.DeleteExpiredVersionsCommand{} + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Failed to delete expired dashboard versions", "error", err.Error()) + } else { + service.log.Debug("Deleted old/expired dashboard versions", "rows affected", cmd.DeletedRows) + } } func (service *CleanUpService) deleteOldLoginAttempts() { diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go new file mode 100644 index 00000000000..02a6ffc8330 --- /dev/null +++ b/pkg/services/dashboards/dashboard_service.go @@ -0,0 +1,256 @@ +package dashboards + +import ( + "strings" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +// DashboardService service for operating on dashboards +type DashboardService interface { + SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) + ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) +} + +// DashboardProvisioningService service for operating on provisioned dashboards +type DashboardProvisioningService interface { + SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) + SaveFolderForProvisionedDashboards(*SaveDashboardDTO) (*models.Dashboard, error) + GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) +} + +// NewService factory for creating a new dashboard service +var NewService = func() DashboardService { + return &dashboardServiceImpl{} +} + +// NewProvisioningService factory for creating a new dashboard provisioning service +var NewProvisioningService = func() DashboardProvisioningService { + return &dashboardServiceImpl{} +} + +type SaveDashboardDTO struct { + OrgId int64 + UpdatedAt time.Time + User *models.SignedInUser + Message string + Overwrite bool + Dashboard *models.Dashboard +} + +type dashboardServiceImpl struct { + orgId int64 + user *models.SignedInUser +} + +func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + cmd := &models.GetProvisionedDashboardDataQuery{Name: name} + err := bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) { + dash := dto.Dashboard + + dash.Title = strings.TrimSpace(dash.Title) + dash.Data.Set("title", dash.Title) + dash.SetUid(strings.TrimSpace(dash.Uid)) + + if dash.Title == "" { + return nil, models.ErrDashboardTitleEmpty + } + + if dash.IsFolder && dash.FolderId > 0 { + return nil, models.ErrDashboardFolderCannotHaveParent + } + + if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(models.RootFolderName) { + return nil, models.ErrDashboardFolderNameExists + } + + if !util.IsValidShortUid(dash.Uid) { + return nil, models.ErrDashboardInvalidUid + } else if len(dash.Uid) > 40 { + return nil, models.ErrDashboardUidToLong + } + + if validateAlerts { + validateAlertsCmd := models.ValidateDashboardAlertsCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + } + + if err := bus.Dispatch(&validateAlertsCmd); err != nil { + return nil, models.ErrDashboardContainsInvalidAlertData + } + } + + validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + Overwrite: dto.Overwrite, + } + + if err := bus.Dispatch(&validateBeforeSaveCmd); err != nil { + return nil, err + } + + guard := guardian.New(dash.GetDashboardIdForSavePermissionCheck(), dto.OrgId, dto.User) + if canSave, err := guard.CanSave(); err != nil || !canSave { + if err != nil { + return nil, err + } + return nil, models.ErrDashboardUpdateAccessDenied + } + + cmd := &models.SaveDashboardCommand{ + Dashboard: dash.Data, + Message: dto.Message, + OrgId: dto.OrgId, + Overwrite: dto.Overwrite, + UserId: dto.User.UserId, + FolderId: dash.FolderId, + IsFolder: dash.IsFolder, + PluginId: dash.PluginId, + } + + if !dto.UpdatedAt.IsZero() { + cmd.UpdatedAt = dto.UpdatedAt + } + + return cmd, nil +} + +func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { + alertCmd := models.UpdateDashboardAlertsCommand{ + OrgId: dto.OrgId, + UserId: dto.User.UserId, + Dashboard: cmd.Result, + } + + if err := bus.Dispatch(&alertCmd); err != nil { + return models.ErrDashboardFailedToUpdateAlertData + } + + return nil +} + +func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + dto.User = &models.SignedInUser{ + UserId: 0, + OrgRole: models.ROLE_ADMIN, + } + cmd, err := dr.buildSaveDashboardCommand(dto, true) + if err != nil { + return nil, err + } + + saveCmd := &models.SaveProvisionedDashboardCommand{ + DashboardCmd: cmd, + DashboardProvisioning: provisioning, + } + + // dashboard + err = bus.Dispatch(saveCmd) + if err != nil { + return nil, err + } + + //alerts + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDashboardDTO) (*models.Dashboard, error) { + dto.User = &models.SignedInUser{ + UserId: 0, + OrgRole: models.ROLE_ADMIN, + } + cmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + cmd, err := dr.buildSaveDashboardCommand(dto, true) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + err = dr.updateAlerting(cmd, dto) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + cmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return nil, err + } + + err = bus.Dispatch(cmd) + if err != nil { + return nil, err + } + + return cmd.Result, nil +} + +type FakeDashboardService struct { + SaveDashboardResult *models.Dashboard + SaveDashboardError error + SavedDashboards []*SaveDashboardDTO +} + +func (s *FakeDashboardService) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + s.SavedDashboards = append(s.SavedDashboards, dto) + + if s.SaveDashboardResult == nil && s.SaveDashboardError == nil { + s.SaveDashboardResult = dto.Dashboard + } + + return s.SaveDashboardResult, s.SaveDashboardError +} + +func (s *FakeDashboardService) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { + return s.SaveDashboard(dto) +} + +func MockDashboardService(mock *FakeDashboardService) { + NewService = func() DashboardService { + return mock + } +} diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go new file mode 100644 index 00000000000..965b10655b3 --- /dev/null +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -0,0 +1,95 @@ +package dashboards + +import ( + "errors" + "testing" + + "github.com/grafana/grafana/pkg/services/guardian" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardService(t *testing.T) { + Convey("Dashboard service tests", t, func() { + service := dashboardServiceImpl{} + + origNewDashboardGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + + Convey("Save dashboard validation", func() { + dto := &SaveDashboardDTO{} + + Convey("When saving a dashboard with empty title it should return error", func() { + titles := []string{"", " ", " \t "} + + for _, title := range titles { + dto.Dashboard = models.NewDashboard(title) + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardTitleEmpty) + } + }) + + Convey("Should return validation error if it's a folder and have a folder id", func() { + dto.Dashboard = models.NewDashboardFolder("Folder") + dto.Dashboard.FolderId = 1 + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardFolderCannotHaveParent) + }) + + Convey("Should return validation error if folder is named General", func() { + dto.Dashboard = models.NewDashboardFolder("General") + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardFolderNameExists) + }) + + Convey("When saving a dashboard should validate uid", func() { + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return nil + }) + + testCases := []struct { + Uid string + Error error + }{ + {Uid: "", Error: nil}, + {Uid: " ", Error: nil}, + {Uid: " \t ", Error: nil}, + {Uid: "asdf90_-", Error: nil}, + {Uid: "asdf/90", Error: models.ErrDashboardInvalidUid}, + {Uid: " asdfghjklqwertyuiopzxcvbnmasdfghjklqwer ", Error: nil}, + {Uid: "asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm", Error: models.ErrDashboardUidToLong}, + } + + for _, tc := range testCases { + dto.Dashboard = models.NewDashboard("title") + dto.Dashboard.SetUid(tc.Uid) + dto.User = &models.SignedInUser{} + + _, err := service.buildSaveDashboardCommand(dto, true) + So(err, ShouldEqual, tc.Error) + } + }) + + Convey("Should return validation error if alert data is invalid", func() { + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return errors.New("error") + }) + + dto.Dashboard = models.NewDashboard("Dash") + _, err := service.SaveDashboard(dto) + So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData) + }) + }) + + Reset(func() { + guardian.New = origNewDashboardGuardian + }) + }) +} diff --git a/pkg/services/dashboards/dashboards.go b/pkg/services/dashboards/dashboards.go deleted file mode 100644 index b0392f7944f..00000000000 --- a/pkg/services/dashboards/dashboards.go +++ /dev/null @@ -1,138 +0,0 @@ -package dashboards - -import ( - "time" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" -) - -type Repository interface { - SaveDashboard(*SaveDashboardDTO) (*models.Dashboard, error) - SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) - GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) -} - -var repositoryInstance Repository - -func GetRepository() Repository { - return repositoryInstance -} - -func SetRepository(rep Repository) { - repositoryInstance = rep -} - -type SaveDashboardDTO struct { - OrgId int64 - UpdatedAt time.Time - UserId int64 - Message string - Overwrite bool - Dashboard *models.Dashboard -} - -type DashboardRepository struct{} - -func (dr *DashboardRepository) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { - cmd := &models.GetProvisionedDashboardDataQuery{Name: name} - err := bus.Dispatch(cmd) - if err != nil { - return nil, err - } - - return cmd.Result, nil -} - -func (dr *DashboardRepository) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) { - dashboard := dto.Dashboard - - if dashboard.Title == "" { - return nil, models.ErrDashboardTitleEmpty - } - - validateAlertsCmd := alerting.ValidateDashboardAlertsCommand{ - OrgId: dto.OrgId, - Dashboard: dashboard, - } - - if err := bus.Dispatch(&validateAlertsCmd); err != nil { - return nil, models.ErrDashboardContainsInvalidAlertData - } - - cmd := &models.SaveDashboardCommand{ - Dashboard: dashboard.Data, - Message: dto.Message, - OrgId: dto.OrgId, - Overwrite: dto.Overwrite, - UserId: dto.UserId, - FolderId: dashboard.FolderId, - IsFolder: dashboard.IsFolder, - } - - if !dto.UpdatedAt.IsZero() { - cmd.UpdatedAt = dto.UpdatedAt - } - - return cmd, nil -} - -func (dr *DashboardRepository) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { - alertCmd := alerting.UpdateDashboardAlertsCommand{ - OrgId: dto.OrgId, - UserId: dto.UserId, - Dashboard: cmd.Result, - } - - if err := bus.Dispatch(&alertCmd); err != nil { - return models.ErrDashboardFailedToUpdateAlertData - } - - return nil -} - -func (dr *DashboardRepository) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto) - if err != nil { - return nil, err - } - - saveCmd := &models.SaveProvisionedDashboardCommand{ - DashboardCmd: cmd, - DashboardProvisioning: provisioning, - } - - // dashboard - err = bus.Dispatch(saveCmd) - if err != nil { - return nil, err - } - - //alerts - err = dr.updateAlerting(cmd, dto) - if err != nil { - return nil, err - } - - return cmd.Result, nil -} - -func (dr *DashboardRepository) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto) - if err != nil { - return nil, err - } - - err = bus.Dispatch(cmd) - if err != nil { - return nil, err - } - - err = dr.updateAlerting(cmd, dto) - if err != nil { - return nil, err - } - - return cmd.Result, nil -} diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go new file mode 100644 index 00000000000..ae92952056e --- /dev/null +++ b/pkg/services/dashboards/folder_service.go @@ -0,0 +1,245 @@ +package dashboards + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/search" +) + +// FolderService service for operating on folders +type FolderService interface { + GetFolders(limit int) ([]*models.Folder, error) + GetFolderByID(id int64) (*models.Folder, error) + GetFolderByUID(uid string) (*models.Folder, error) + CreateFolder(cmd *models.CreateFolderCommand) error + UpdateFolder(uid string, cmd *models.UpdateFolderCommand) error + DeleteFolder(uid string) (*models.Folder, error) +} + +// NewFolderService factory for creating a new folder service +var NewFolderService = func(orgId int64, user *models.SignedInUser) FolderService { + return &dashboardServiceImpl{ + orgId: orgId, + user: user, + } +} + +func (dr *dashboardServiceImpl) GetFolders(limit int) ([]*models.Folder, error) { + if limit == 0 { + limit = 1000 + } + + searchQuery := search.Query{ + SignedInUser: dr.user, + DashboardIds: make([]int64, 0), + FolderIds: make([]int64, 0), + Limit: limit, + OrgId: dr.orgId, + Type: "dash-folder", + Permission: models.PERMISSION_VIEW, + } + + if err := bus.Dispatch(&searchQuery); err != nil { + return nil, err + } + + folders := make([]*models.Folder, 0) + + for _, hit := range searchQuery.Result { + folders = append(folders, &models.Folder{ + Id: hit.Id, + Uid: hit.Uid, + Title: hit.Title, + }) + } + + return folders, nil +} + +func (dr *dashboardServiceImpl) GetFolderByID(id int64) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) GetFolderByUID(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) error { + dashFolder := cmd.GetDashboardModel(dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid} + dashFolder, err := getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + Overwrite: cmd.Overwrite, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query = models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) DeleteFolder(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + if err != nil { + return nil, toFolderError(err) + } + + guardian := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id} + if err := bus.Dispatch(&deleteCmd); err != nil { + return nil, toFolderError(err) + } + + return dashToFolder(dashFolder), nil +} + +func getFolder(query models.GetDashboardQuery) (*models.Dashboard, error) { + if err := bus.Dispatch(&query); err != nil { + return nil, toFolderError(err) + } + + if !query.Result.IsFolder { + return nil, models.ErrFolderNotFound + } + + return query.Result, nil +} + +func dashToFolder(dash *models.Dashboard) *models.Folder { + return &models.Folder{ + Id: dash.Id, + Uid: dash.Uid, + Title: dash.Title, + HasAcl: dash.HasAcl, + Url: dash.GetUrl(), + Version: dash.Version, + Created: dash.Created, + CreatedBy: dash.CreatedBy, + Updated: dash.Updated, + UpdatedBy: dash.UpdatedBy, + } +} + +func toFolderError(err error) error { + if err == models.ErrDashboardTitleEmpty { + return models.ErrFolderTitleEmpty + } + + if err == models.ErrDashboardUpdateAccessDenied { + return models.ErrFolderAccessDenied + } + + if err == models.ErrDashboardWithSameNameInFolderExists { + return models.ErrFolderSameNameExists + } + + if err == models.ErrDashboardWithSameUIDExists { + return models.ErrFolderWithSameUIDExists + } + + if err == models.ErrDashboardVersionMismatch { + return models.ErrFolderVersionMismatch + } + + if err == models.ErrDashboardNotFound { + return models.ErrFolderNotFound + } + + if err == models.ErrDashboardFailedGenerateUniqueUid { + err = models.ErrFolderFailedGenerateUniqueUid + } + + return err +} diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/folder_service_test.go new file mode 100644 index 00000000000..6c0413d1878 --- /dev/null +++ b/pkg/services/dashboards/folder_service_test.go @@ -0,0 +1,191 @@ +package dashboards + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderService(t *testing.T) { + Convey("Folder service tests", t, func() { + service := dashboardServiceImpl{ + orgId: 1, + user: &models.SignedInUser{UserId: 1}, + } + + Convey("Given user has no permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = models.NewDashboardFolder("Folder") + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return models.ErrDashboardUpdateAccessDenied + }) + + Convey("When get folder by id should return access denied error", func() { + _, err := service.GetFolderByID(1) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When get folder by uid should return access denied error", func() { + _, err := service.GetFolderByUID("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When creating folder should return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When updating folder should return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When deleting folder by uid should return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to save", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + + dash := models.NewDashboardFolder("Folder") + dash.Id = 1 + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveDashboardCommand) error { + cmd.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { + return nil + }) + + Convey("When creating folder should not return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When updating folder should not return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When deleting folder by uid should not return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldBeNil) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to view", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) + + dashFolder := models.NewDashboardFolder("Folder") + dashFolder.Id = 1 + dashFolder.Uid = "uid-abc" + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dashFolder + return nil + }) + + Convey("When get folder by id should return folder", func() { + f, _ := service.GetFolderByID(1) + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Convey("When get folder by uid should return folder", func() { + f, _ := service.GetFolderByUID("uid") + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Should map errors correct", func() { + testCases := []struct { + ActualError error + ExpectedError error + }{ + {ActualError: models.ErrDashboardTitleEmpty, ExpectedError: models.ErrFolderTitleEmpty}, + {ActualError: models.ErrDashboardUpdateAccessDenied, ExpectedError: models.ErrFolderAccessDenied}, + {ActualError: models.ErrDashboardWithSameNameInFolderExists, ExpectedError: models.ErrFolderSameNameExists}, + {ActualError: models.ErrDashboardWithSameUIDExists, ExpectedError: models.ErrFolderWithSameUIDExists}, + {ActualError: models.ErrDashboardVersionMismatch, ExpectedError: models.ErrFolderVersionMismatch}, + {ActualError: models.ErrDashboardNotFound, ExpectedError: models.ErrFolderNotFound}, + {ActualError: models.ErrDashboardFailedGenerateUniqueUid, ExpectedError: models.ErrFolderFailedGenerateUniqueUid}, + {ActualError: models.ErrDashboardInvalidUid, ExpectedError: models.ErrDashboardInvalidUid}, + } + + for _, tc := range testCases { + actualError := toFolderError(tc.ActualError) + if actualError != tc.ExpectedError { + t.Errorf("For error '%s' expected error '%s', actual '%s'", tc.ActualError, tc.ExpectedError, actualError) + } + } + }) + }) +} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 05795b7f2df..811b38cac86 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -1,13 +1,31 @@ package guardian import ( + "errors" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -type DashboardGuardian struct { +var ( + ErrGuardianPermissionExists = errors.New("Permission already exists") + ErrGuardianOverride = errors.New("You can only override a permission to be higher") +) + +// DashboardGuardian to be used for guard against operations without access on dashboard and acl +type DashboardGuardian interface { + CanSave() (bool, error) + CanEdit() (bool, error) + CanView() (bool, error) + CanAdmin() (bool, error) + HasPermission(permission m.PermissionType) (bool, error) + CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) + GetAcl() ([]*m.DashboardAclInfoDTO, error) +} + +type dashboardGuardianImpl struct { user *m.SignedInUser dashId int64 orgId int64 @@ -16,8 +34,9 @@ type DashboardGuardian struct { log log.Logger } -func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *DashboardGuardian { - return &DashboardGuardian{ +// New factory for creating a new dashboard guardian instance +var New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian { + return &dashboardGuardianImpl{ user: user, dashId: dashId, orgId: orgId, @@ -25,11 +44,11 @@ func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *Dash } } -func (g *DashboardGuardian) CanSave() (bool, error) { +func (g *dashboardGuardianImpl) CanSave() (bool, error) { return g.HasPermission(m.PERMISSION_EDIT) } -func (g *DashboardGuardian) CanEdit() (bool, error) { +func (g *dashboardGuardianImpl) CanEdit() (bool, error) { if setting.ViewersCanEdit { return g.HasPermission(m.PERMISSION_VIEW) } @@ -37,15 +56,15 @@ func (g *DashboardGuardian) CanEdit() (bool, error) { return g.HasPermission(m.PERMISSION_EDIT) } -func (g *DashboardGuardian) CanView() (bool, error) { +func (g *dashboardGuardianImpl) CanView() (bool, error) { return g.HasPermission(m.PERMISSION_VIEW) } -func (g *DashboardGuardian) CanAdmin() (bool, error) { +func (g *dashboardGuardianImpl) CanAdmin() (bool, error) { return g.HasPermission(m.PERMISSION_ADMIN) } -func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { +func (g *dashboardGuardianImpl) HasPermission(permission m.PermissionType) (bool, error) { if g.user.OrgRole == m.ROLE_ADMIN { return true, nil } @@ -58,7 +77,7 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er return g.checkAcl(permission, acl) } -func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { +func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { orgRole := g.user.OrgRole teamAclItems := []*m.DashboardAclInfoDTO{} @@ -106,22 +125,59 @@ func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.Dashb return false, nil } -func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { - if g.user.OrgRole == m.ROLE_ADMIN { - return true, nil +func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + acl := []*m.DashboardAclInfoDTO{} + adminRole := m.ROLE_ADMIN + everyoneWithAdminRole := &m.DashboardAclInfoDTO{DashboardId: g.dashId, UserId: 0, TeamId: 0, Role: &adminRole, Permission: m.PERMISSION_ADMIN} + + // validate that duplicate permissions don't exists + for _, p := range updatePermissions { + aclItem := &m.DashboardAclInfoDTO{DashboardId: p.DashboardId, UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission} + if aclItem.IsDuplicateOf(everyoneWithAdminRole) { + return false, ErrGuardianPermissionExists + } + + for _, a := range acl { + if a.IsDuplicateOf(aclItem) { + return false, ErrGuardianPermissionExists + } + } + + acl = append(acl, aclItem) } - acl := []*m.DashboardAclInfoDTO{} + existingPermissions, err := g.GetAcl() + if err != nil { + return false, err + } - for _, p := range updatePermissions { - acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}) + // validate overridden permissions to be higher + for _, a := range acl { + for _, existingPerm := range existingPermissions { + // handle default permissions + if existingPerm.DashboardId == -1 { + existingPerm.DashboardId = g.dashId + } + + if a.DashboardId == existingPerm.DashboardId { + continue + } + + if a.IsDuplicateOf(existingPerm) && a.Permission <= existingPerm.Permission { + return false, ErrGuardianOverride + } + } + } + + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil } return g.checkAcl(permission, acl) } // GetAcl returns dashboard acl -func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { +func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) { if g.acl != nil { return g.acl, nil } @@ -131,11 +187,18 @@ func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { return nil, err } + for _, a := range query.Result { + // handle default permissions + if a.DashboardId == -1 { + a.DashboardId = g.dashId + } + } + g.acl = query.Result return g.acl, nil } -func (g *DashboardGuardian) getTeams() ([]*m.Team, error) { +func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { if g.groups != nil { return g.groups, nil } @@ -146,3 +209,54 @@ func (g *DashboardGuardian) getTeams() ([]*m.Team, error) { g.groups = query.Result return query.Result, err } + +type FakeDashboardGuardian struct { + DashId int64 + OrgId int64 + User *m.SignedInUser + CanSaveValue bool + CanEditValue bool + CanViewValue bool + CanAdminValue bool + HasPermissionValue bool + CheckPermissionBeforeUpdateValue bool + CheckPermissionBeforeUpdateError error + GetAclValue []*m.DashboardAclInfoDTO +} + +func (g *FakeDashboardGuardian) CanSave() (bool, error) { + return g.CanSaveValue, nil +} + +func (g *FakeDashboardGuardian) CanEdit() (bool, error) { + return g.CanEditValue, nil +} + +func (g *FakeDashboardGuardian) CanView() (bool, error) { + return g.CanViewValue, nil +} + +func (g *FakeDashboardGuardian) CanAdmin() (bool, error) { + return g.CanAdminValue, nil +} + +func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { + return g.HasPermissionValue, nil +} + +func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + return g.CheckPermissionBeforeUpdateValue, g.CheckPermissionBeforeUpdateError +} + +func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { + return g.GetAclValue, nil +} + +func MockDashboardGuardian(mock *FakeDashboardGuardian) { + New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian { + mock.OrgId = orgId + mock.DashId = dashId + mock.User = user + return mock + } +} diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go new file mode 100644 index 00000000000..bb7e6bd1a72 --- /dev/null +++ b/pkg/services/guardian/guardian_test.go @@ -0,0 +1,711 @@ +package guardian + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/bus" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGuardian(t *testing.T) { + Convey("Guardian permission tests", t, func() { + orgRoleScenario("Given user has admin org role", m.ROLE_ADMIN, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + + Convey("When trying to update permissions", func() { + Convey("With duplicate user permissions should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate team permissions should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate everyone with editor role permission should return error", func() { + r := m.ROLE_EDITOR + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate everyone with viewer role permission should return error", func() { + r := m.ROLE_VIEWER + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With everyone with admin role permission should return error", func() { + r := m.ROLE_ADMIN + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + }) + + Convey("Given default permissions", func() { + editor := m.ROLE_EDITOR + viewer := m.ROLE_VIEWER + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: -1, Role: &editor, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: -1, Role: &viewer, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions without everyone with role editor can edit should be allowed", func() { + r := m.ROLE_VIEWER + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions without everyone with role viewer can view should be allowed", func() { + r := m.ROLE_EDITOR + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + }) + + Convey("Given parent folder has user admin permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with edit user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has user edit permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has user view permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team admin permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with edit team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team edit permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team view permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has editor role with edit permission", func() { + r := m.ROLE_EDITOR + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can admin permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can edit permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can view permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has editor role with view permission", func() { + r := m.ROLE_EDITOR + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can admin permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can edit permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can view permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + }) + + orgRoleScenario("Given user has editor org role", m.ROLE_EDITOR, func(sc *scenarioContext) { + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + Convey("When trying to update permissions should return false", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeFalse) + }) + }) + + orgRoleScenario("Given user has viewer org role", m.ROLE_VIEWER, func(sc *scenarioContext) { + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + Convey("When trying to update permissions should return false", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeFalse) + }) + }) + }) +} + +type scenarioContext struct { + g DashboardGuardian +} + +type scenarioFunc func(c *scenarioContext) + +func orgRoleScenario(desc string, role m.RoleType, fn scenarioFunc) { + user := &m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: role, + } + guard := New(1, 1, user) + sc := &scenarioContext{ + g: guard, + } + + Convey(desc, func() { + fn(sc) + }) +} + +func permissionScenario(desc string, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) { + bus.ClearBusHandlers() + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = permissions + return nil + }) + + teams := []*m.Team{} + + for _, p := range permissions { + if p.TeamId > 0 { + teams = append(teams, &m.Team{Id: p.TeamId}) + } + } + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = teams + return nil + }) + + Convey(desc, func() { + fn(sc) + }) +} + +func userWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and user has permission to %s item", permission), sc, p, fn) +} + +func teamWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and team has permission to %s item", permission), sc, p, fn) +} + +func everyoneWithRoleScenario(role m.RoleType, permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: -1, Role: &role, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and everyone with %s role can %s item", role, permission), sc, p, fn) +} diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 7fbf39ee41d..05c2e53c748 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - "gopkg.in/gomail.v2" + gomail "gopkg.in/mail.v2" ) var mailQueue chan *Message diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index ab9e85f4d38..9030ba609b9 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -2,6 +2,7 @@ package dashboards import ( "io/ioutil" + "os" "path/filepath" "strings" @@ -14,11 +15,48 @@ type configReader struct { log log.Logger } +func (cr *configReader) parseConfigs(file os.FileInfo) ([]*DashboardsAsConfig, error) { + filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name())) + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + apiVersion := &ConfigVersion{ApiVersion: 0} + yaml.Unmarshal(yamlFile, &apiVersion) + + if apiVersion.ApiVersion > 0 { + + v1 := &DashboardAsConfigV1{} + err := yaml.Unmarshal(yamlFile, &v1) + if err != nil { + return nil, err + } + + if v1 != nil { + return v1.mapToDashboardAsConfig(), nil + } + + } else { + var v0 []*DashboardsAsConfigV0 + err := yaml.Unmarshal(yamlFile, &v0) + if err != nil { + return nil, err + } + + if v0 != nil { + cr.log.Warn("[Deprecated] the dashboard provisioning config is outdated. please upgrade", "filename", filename) + return mapV0ToDashboardAsConfig(v0), nil + } + } + + return []*DashboardsAsConfig{}, nil +} + func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { var dashboards []*DashboardsAsConfig files, err := ioutil.ReadDir(cr.path) - if err != nil { cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path) return dashboards, nil @@ -29,19 +67,14 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { continue } - filename, _ := filepath.Abs(filepath.Join(cr.path, file.Name())) - yamlFile, err := ioutil.ReadFile(filename) + parsedDashboards, err := cr.parseConfigs(file) if err != nil { - return nil, err + } - var dashCfg []*DashboardsAsConfig - err = yaml.Unmarshal(yamlFile, &dashCfg) - if err != nil { - return nil, err + if len(parsedDashboards) > 0 { + dashboards = append(dashboards, parsedDashboards...) } - - dashboards = append(dashboards, dashCfg...) } for i := range dashboards { diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index bb960a72094..ecbf6435c36 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -9,48 +9,33 @@ import ( var ( simpleDashboardConfig string = "./test-configs/dashboards-from-disk" + oldVersion string = "./test-configs/version-0" brokenConfigs string = "./test-configs/broken-configs" ) func TestDashboardsAsConfig(t *testing.T) { Convey("Dashboards as configuration", t, func() { + logger := log.New("test-logger") - Convey("Can read config file", func() { - - cfgProvider := configReader{path: simpleDashboardConfig, log: log.New("test-logger")} + Convey("Can read config file version 1 format", func() { + cfgProvider := configReader{path: simpleDashboardConfig, log: logger} cfg, err := cfgProvider.readConfig() - if err != nil { - t.Fatalf("readConfig return an error %v", err) - } + So(err, ShouldBeNil) - So(len(cfg), ShouldEqual, 2) + validateDashboardAsConfig(cfg) + }) - ds := cfg[0] + Convey("Can read config file in version 0 format", func() { + cfgProvider := configReader{path: oldVersion, log: logger} + cfg, err := cfgProvider.readConfig() + So(err, ShouldBeNil) - So(ds.Name, ShouldEqual, "general dashboards") - So(ds.Type, ShouldEqual, "file") - So(ds.OrgId, ShouldEqual, 2) - So(ds.Folder, ShouldEqual, "developers") - So(ds.Editable, ShouldBeTrue) - - So(len(ds.Options), ShouldEqual, 1) - So(ds.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") - - ds2 := cfg[1] - - So(ds2.Name, ShouldEqual, "default") - So(ds2.Type, ShouldEqual, "file") - So(ds2.OrgId, ShouldEqual, 1) - So(ds2.Folder, ShouldEqual, "") - So(ds2.Editable, ShouldBeFalse) - - So(len(ds2.Options), ShouldEqual, 1) - So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") + validateDashboardAsConfig(cfg) }) Convey("Should skip invalid path", func() { - cfgProvider := configReader{path: "/invalid-directory", log: log.New("test-logger")} + cfgProvider := configReader{path: "/invalid-directory", log: logger} cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -61,7 +46,7 @@ func TestDashboardsAsConfig(t *testing.T) { Convey("Should skip broken config files", func() { - cfgProvider := configReader{path: brokenConfigs, log: log.New("test-logger")} + cfgProvider := configReader{path: brokenConfigs, log: logger} cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -71,3 +56,26 @@ func TestDashboardsAsConfig(t *testing.T) { }) }) } +func validateDashboardAsConfig(cfg []*DashboardsAsConfig) { + So(len(cfg), ShouldEqual, 2) + + ds := cfg[0] + So(ds.Name, ShouldEqual, "general dashboards") + So(ds.Type, ShouldEqual, "file") + So(ds.OrgId, ShouldEqual, 2) + So(ds.Folder, ShouldEqual, "developers") + So(ds.Editable, ShouldBeTrue) + So(len(ds.Options), ShouldEqual, 1) + So(ds.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") + So(ds.DisableDeletion, ShouldBeTrue) + + ds2 := cfg[1] + So(ds2.Name, ShouldEqual, "default") + So(ds2.Type, ShouldEqual, "file") + So(ds2.OrgId, ShouldEqual, 1) + So(ds2.Folder, ShouldEqual, "") + So(ds2.Editable, ShouldBeFalse) + So(len(ds2.Options), ShouldEqual, 1) + So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") + So(ds2.DisableDeletion, ShouldBeFalse) +} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index c909878999e..de0a49d34d9 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -25,10 +25,10 @@ var ( ) type fileReader struct { - Cfg *DashboardsAsConfig - Path string - log log.Logger - dashboardRepo dashboards.Repository + Cfg *DashboardsAsConfig + Path string + log log.Logger + dashboardService dashboards.DashboardProvisioningService } func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) { @@ -48,10 +48,10 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade } return &fileReader{ - Cfg: cfg, - Path: path, - log: log, - dashboardRepo: dashboards.GetRepository(), + Cfg: cfg, + Path: path, + log: log, + dashboardService: dashboards.NewProvisioningService(), }, nil } @@ -89,12 +89,12 @@ func (fr *fileReader) startWalkingDisk() error { } } - folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardRepo) + folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardService) if err != nil && err != ErrFolderNameMissing { return err } - provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardRepo, fr.Cfg.Name) + provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardService, fr.Cfg.Name) if err != nil { return err } @@ -105,24 +105,7 @@ func (fr *fileReader) startWalkingDisk() error { return err } - // find dashboards to delete since json file is missing - var dashboardToDelete []int64 - for path, provisioningData := range provisionedDashboardRefs { - _, existsOnDisk := filesFoundOnDisk[path] - if !existsOnDisk { - dashboardToDelete = append(dashboardToDelete, provisioningData.DashboardId) - } - } - - // delete dashboard that are missing json file - for _, dashboardId := range dashboardToDelete { - fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) - cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} - err := bus.Dispatch(cmd) - if err != nil { - fr.log.Error("failed to delete dashboard", "id", cmd.Id) - } - } + fr.deleteDashboardIfFileIsMissing(provisionedDashboardRefs, filesFoundOnDisk) sanityChecker := newProvisioningSanityChecker(fr.Cfg.Name) @@ -138,6 +121,29 @@ func (fr *fileReader) startWalkingDisk() error { return nil } +func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) { + if fr.Cfg.DisableDeletion { + return + } + + // find dashboards to delete since json file is missing + var dashboardToDelete []int64 + for path, provisioningData := range provisionedDashboardRefs { + _, existsOnDisk := filesFoundOnDisk[path] + if !existsOnDisk { + dashboardToDelete = append(dashboardToDelete, provisioningData.DashboardId) + } + } + // delete dashboard that are missing json file + for _, dashboardId := range dashboardToDelete { + fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) + cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} + err := bus.Dispatch(cmd) + if err != nil { + fr.log.Error("failed to delete dashboard", "id", cmd.Id) + } + } +} func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.FileInfo, provisionedDashboardRefs map[string]*models.DashboardProvisioning) (provisioningMetadata, error) { provisioningMetadata := provisioningMetadata{} @@ -147,7 +153,7 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil } provisionedData, alreadyProvisioned := provisionedDashboardRefs[path] - upToDate := alreadyProvisioned && provisionedData.Updated.Unix() == resolvedFileInfo.ModTime().Unix() + upToDate := alreadyProvisioned && provisionedData.Updated == resolvedFileInfo.ModTime().Unix() dash, err := fr.readDashboardFromFile(path, resolvedFileInfo.ModTime(), folderId) if err != nil { @@ -164,8 +170,8 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil } if dash.Dashboard.Id != 0 { - fr.log.Error("provisioned dashboard json files cannot contain id") - return provisioningMetadata, nil + dash.Dashboard.Data.Set("id", nil) + dash.Dashboard.Id = 0 } if alreadyProvisioned { @@ -173,13 +179,13 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil } fr.log.Debug("saving new dashboard", "file", path) - dp := &models.DashboardProvisioning{ExternalId: path, Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime()} - _, err = fr.dashboardRepo.SaveProvisionedDashboard(dash, dp) + dp := &models.DashboardProvisioning{ExternalId: path, Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime().Unix()} + _, err = fr.dashboardService.SaveProvisionedDashboard(dash, dp) return provisioningMetadata, err } -func getProvisionedDashboardByPath(repo dashboards.Repository, name string) (map[string]*models.DashboardProvisioning, error) { - arr, err := repo.GetProvisionedDashboardData(name) +func getProvisionedDashboardByPath(service dashboards.DashboardProvisioningService, name string) (map[string]*models.DashboardProvisioning, error) { + arr, err := service.GetProvisionedDashboardData(name) if err != nil { return nil, err } @@ -192,7 +198,7 @@ func getProvisionedDashboardByPath(repo dashboards.Repository, name string) (map return byPath, nil } -func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (int64, error) { +func getOrCreateFolderId(cfg *DashboardsAsConfig, service dashboards.DashboardProvisioningService) (int64, error) { if cfg.Folder == "" { return 0, ErrFolderNameMissing } @@ -207,11 +213,11 @@ func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (i // dashboard folder not found. create one. if err == models.ErrDashboardNotFound { dash := &dashboards.SaveDashboardDTO{} - dash.Dashboard = models.NewDashboard(cfg.Folder) + dash.Dashboard = models.NewDashboardFolder(cfg.Folder) dash.Dashboard.IsFolder = true dash.Overwrite = true dash.OrgId = cfg.OrgId - dbDash, err := repo.SaveDashboard(dash) + dbDash, err := service.SaveFolderForProvisionedDashboards(dash) if err != nil { return 0, err } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index a81b502c50a..8a301987ea6 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -15,20 +15,21 @@ import ( ) var ( - defaultDashboards string = "./test-dashboards/folder-one" - brokenDashboards string = "./test-dashboards/broken-dashboards" - oneDashboard string = "./test-dashboards/one-dashboard" + defaultDashboards = "./test-dashboards/folder-one" + brokenDashboards = "./test-dashboards/broken-dashboards" + oneDashboard = "./test-dashboards/one-dashboard" + containingId = "./test-dashboards/containing-id" - fakeRepo *fakeDashboardRepo + fakeService *fakeDashboardProvisioningService ) func TestDashboardFileReader(t *testing.T) { Convey("Dashboard file reader", t, func() { bus.ClearBusHandlers() - fakeRepo = &fakeDashboardRepo{} + origNewDashboardProvisioningService := dashboards.NewProvisioningService + fakeService = mockDashboardProvisioningService() bus.AddHandler("test", mockGetDashboardQuery) - dashboards.SetRepository(fakeRepo) logger := log.New("test.logger") Convey("Reading dashboards from disk", func() { @@ -54,7 +55,7 @@ func TestDashboardFileReader(t *testing.T) { folders := 0 dashboards := 0 - for _, i := range fakeRepo.inserted { + for _, i := range fakeService.inserted { if i.Dashboard.IsFolder { folders++ } else { @@ -71,7 +72,7 @@ func TestDashboardFileReader(t *testing.T) { stat, _ := os.Stat(oneDashboard + "/dashboard1.json") - fakeRepo.getDashboard = append(fakeRepo.getDashboard, &models.Dashboard{ + fakeService.getDashboard = append(fakeService.getDashboard, &models.Dashboard{ Updated: stat.ModTime().AddDate(0, 0, -1), Slug: "grafana", }) @@ -82,7 +83,19 @@ func TestDashboardFileReader(t *testing.T) { err = reader.startWalkingDisk() So(err, ShouldBeNil) - So(len(fakeRepo.inserted), ShouldEqual, 1) + So(len(fakeService.inserted), ShouldEqual, 1) + }) + + Convey("Overrides id from dashboard.json files", func() { + cfg.Options["path"] = containingId + + reader, err := NewDashboardFileReader(cfg, logger) + So(err, ShouldBeNil) + + err = reader.startWalkingDisk() + So(err, ShouldBeNil) + + So(len(fakeService.inserted), ShouldEqual, 1) }) Convey("Invalid configuration should return error", func() { @@ -116,7 +129,7 @@ func TestDashboardFileReader(t *testing.T) { }, } - _, err := getOrCreateFolderId(cfg, fakeRepo) + _, err := getOrCreateFolderId(cfg, fakeService) So(err, ShouldEqual, ErrFolderNameMissing) }) @@ -131,15 +144,15 @@ func TestDashboardFileReader(t *testing.T) { }, } - folderId, err := getOrCreateFolderId(cfg, fakeRepo) + folderId, err := getOrCreateFolderId(cfg, fakeService) So(err, ShouldBeNil) inserted := false - for _, d := range fakeRepo.inserted { + for _, d := range fakeService.inserted { if d.Dashboard.IsFolder && d.Dashboard.Id == folderId { inserted = true } } - So(len(fakeRepo.inserted), ShouldEqual, 1) + So(len(fakeService.inserted), ShouldEqual, 1) So(inserted, ShouldBeTrue) }) @@ -180,6 +193,10 @@ func TestDashboardFileReader(t *testing.T) { So(reader.Path, ShouldEqual, defaultDashboards) }) }) + + Reset(func() { + dashboards.NewProvisioningService = origNewDashboardProvisioningService + }) }) } @@ -212,29 +229,37 @@ func (ffi FakeFileInfo) Sys() interface{} { return nil } -type fakeDashboardRepo struct { +func mockDashboardProvisioningService() *fakeDashboardProvisioningService { + mock := fakeDashboardProvisioningService{} + dashboards.NewProvisioningService = func() dashboards.DashboardProvisioningService { + return &mock + } + return &mock +} + +type fakeDashboardProvisioningService struct { inserted []*dashboards.SaveDashboardDTO provisioned []*models.DashboardProvisioning getDashboard []*models.Dashboard } -func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { - repo.inserted = append(repo.inserted, json) - return json.Dashboard, nil +func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + return s.provisioned, nil } -func (repo *fakeDashboardRepo) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { - return repo.provisioned, nil +func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + s.inserted = append(s.inserted, dto) + s.provisioned = append(s.provisioned, provisioning) + return dto.Dashboard, nil } -func (repo *fakeDashboardRepo) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { - repo.inserted = append(repo.inserted, dto) - repo.provisioned = append(repo.provisioned, provisioning) +func (s *fakeDashboardProvisioningService) SaveFolderForProvisionedDashboards(dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { + s.inserted = append(s.inserted, dto) return dto.Dashboard, nil } func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error { - for _, d := range fakeRepo.getDashboard { + for _, d := range fakeService.getDashboard { if d.Slug == cmd.Slug { cmd.Result = d return nil diff --git a/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml b/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml index e40612af508..f0dcca9b47a 100644 --- a/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml +++ b/pkg/services/provisioning/dashboards/test-configs/broken-configs/commented.yaml @@ -3,4 +3,4 @@ # folder: '' # type: file # options: -# folder: /var/lib/grafana/dashboards +# path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml index df0e6ff3044..e9776d69010 100644 --- a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml +++ b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/dev-dashboards.yaml @@ -1,7 +1,11 @@ +apiVersion: 1 + +providers: - name: 'general dashboards' - org_id: 2 + orgId: 2 folder: 'developers' editable: true + disableDeletion: true type: file options: path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml new file mode 100644 index 00000000000..5b73632b1ff --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-configs/dashboards-from-disk/sample.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +#providers: +#- name: 'gasdf' +# orgId: 2 +# folder: 'developers' +# editable: true +# type: file +# options: +# path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml b/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml new file mode 100644 index 00000000000..979e762d4d4 --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-configs/version-0/version-0.yaml @@ -0,0 +1,13 @@ +- name: 'general dashboards' + org_id: 2 + folder: 'developers' + editable: true + disableDeletion: true + type: file + options: + path: /var/lib/grafana/dashboards + +- name: 'default' + type: file + options: + path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/test-dashboards/containing-id/dashboard1.json b/pkg/services/provisioning/dashboards/test-dashboards/containing-id/dashboard1.json new file mode 100644 index 00000000000..12a8b81eee6 --- /dev/null +++ b/pkg/services/provisioning/dashboards/test-dashboards/containing-id/dashboard1.json @@ -0,0 +1,68 @@ +{ + "title": "Grafana1", + "tags": [], + "id": 3, + "style": "dark", + "timezone": "browser", + "editable": true, + "rows": [ + { + "title": "New row", + "height": "150px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 1, + "span": 12, + "editable": true, + "type": "text", + "mode": "html", + "content": "
\n \n
", + "style": {}, + "title": "Welcome to" + } + ] + } + ], + "nav": [ + { + "type": "timepicker", + "collapse": false, + "enable": true, + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "now": true + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [] + }, + "version": 5 + } diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 91379b33148..f742b321552 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -10,12 +10,41 @@ import ( ) type DashboardsAsConfig struct { - Name string `json:"name" yaml:"name"` - Type string `json:"type" yaml:"type"` - OrgId int64 `json:"org_id" yaml:"org_id"` - Folder string `json:"folder" yaml:"folder"` - Editable bool `json:"editable" yaml:"editable"` - Options map[string]interface{} `json:"options" yaml:"options"` + Name string + Type string + OrgId int64 + Folder string + Editable bool + Options map[string]interface{} + DisableDeletion bool +} + +type DashboardsAsConfigV0 struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + OrgId int64 `json:"org_id" yaml:"org_id"` + Folder string `json:"folder" yaml:"folder"` + Editable bool `json:"editable" yaml:"editable"` + Options map[string]interface{} `json:"options" yaml:"options"` + DisableDeletion bool `json:"disableDeletion" yaml:"disableDeletion"` +} + +type ConfigVersion struct { + ApiVersion int64 `json:"apiVersion" yaml:"apiVersion"` +} + +type DashboardAsConfigV1 struct { + Providers []*DashboardProviderConfigs `json:"providers" yaml:"providers"` +} + +type DashboardProviderConfigs struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + OrgId int64 `json:"orgId" yaml:"orgId"` + Folder string `json:"folder" yaml:"folder"` + Editable bool `json:"editable" yaml:"editable"` + Options map[string]interface{} `json:"options" yaml:"options"` + DisableDeletion bool `json:"disableDeletion" yaml:"disableDeletion"` } func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardDTO, error) { @@ -36,3 +65,39 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das return dash, nil } + +func mapV0ToDashboardAsConfig(v0 []*DashboardsAsConfigV0) []*DashboardsAsConfig { + var r []*DashboardsAsConfig + + for _, v := range v0 { + r = append(r, &DashboardsAsConfig{ + Name: v.Name, + Type: v.Type, + OrgId: v.OrgId, + Folder: v.Folder, + Editable: v.Editable, + Options: v.Options, + DisableDeletion: v.DisableDeletion, + }) + } + + return r +} + +func (dc *DashboardAsConfigV1) mapToDashboardAsConfig() []*DashboardsAsConfig { + var r []*DashboardsAsConfig + + for _, v := range dc.Providers { + r = append(r, &DashboardsAsConfig{ + Name: v.Name, + Type: v.Type, + OrgId: v.OrgId, + Folder: v.Folder, + Editable: v.Editable, + Options: v.Options, + DisableDeletion: v.DisableDeletion, + }) + } + + return r +} diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go new file mode 100644 index 00000000000..58ed5472a6b --- /dev/null +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -0,0 +1,113 @@ +package datasources + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/log" + "gopkg.in/yaml.v2" +) + +type configReader struct { + log log.Logger +} + +func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { + var datasources []*DatasourcesAsConfig + + files, err := ioutil.ReadDir(path) + if err != nil { + cr.log.Error("cant read datasource provisioning files from directory", "path", path) + return datasources, nil + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { + datasource, err := cr.parseDatasourceConfig(path, file) + if err != nil { + return nil, err + } + + if datasource != nil { + datasources = append(datasources, datasource) + } + } + } + + err = validateDefaultUniqueness(datasources) + if err != nil { + return nil, err + } + + return datasources, nil +} + +func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*DatasourcesAsConfig, error) { + filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var apiVersion *ConfigVersion + err = yaml.Unmarshal(yamlFile, &apiVersion) + if err != nil { + return nil, err + } + + if apiVersion == nil { + apiVersion = &ConfigVersion{ApiVersion: 0} + } + + if apiVersion.ApiVersion > 0 { + var v1 *DatasourcesAsConfigV1 + err = yaml.Unmarshal(yamlFile, &v1) + if err != nil { + return nil, err + } + + return v1.mapToDatasourceFromConfig(apiVersion.ApiVersion), nil + } + + var v0 *DatasourcesAsConfigV0 + err = yaml.Unmarshal(yamlFile, &v0) + if err != nil { + return nil, err + } + + cr.log.Warn("[Deprecated] the datasource provisioning config is outdated. please upgrade", "filename", filename) + + return v0.mapToDatasourceFromConfig(apiVersion.ApiVersion), nil +} + +func validateDefaultUniqueness(datasources []*DatasourcesAsConfig) error { + defaultCount := 0 + for i := range datasources { + if datasources[i].Datasources == nil { + continue + } + + for _, ds := range datasources[i].Datasources { + if ds.OrgId == 0 { + ds.OrgId = 1 + } + + if ds.IsDefault { + defaultCount++ + if defaultCount > 1 { + return ErrInvalidConfigToManyDefault + } + } + } + + for _, ds := range datasources[i].DeleteDatasources { + if ds.OrgId == 0 { + ds.OrgId = 1 + } + } + } + + return nil +} diff --git a/pkg/services/provisioning/datasources/datasources_test.go b/pkg/services/provisioning/datasources/config_reader_test.go similarity index 69% rename from pkg/services/provisioning/datasources/datasources_test.go rename to pkg/services/provisioning/datasources/config_reader_test.go index 00dc59f6617..3198329e0ae 100644 --- a/pkg/services/provisioning/datasources/datasources_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -17,6 +17,7 @@ var ( twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two" doubleDatasourcesConfig string = "./test-configs/double-default" allProperties string = "./test-configs/all-properties" + versionZero string = "./test-configs/version-0" brokenYaml string = "./test-configs/broken-yaml" fakeRepo *fakeRepository @@ -130,48 +131,86 @@ func TestDatasourceAsConfig(t *testing.T) { So(len(cfg), ShouldEqual, 0) }) - Convey("can read all properties", func() { + Convey("can read all properties from version 1", func() { cfgProvifer := &configReader{log: log.New("test logger")} cfg, err := cfgProvifer.readConfig(allProperties) if err != nil { t.Fatalf("readConfig return an error %v", err) } - So(len(cfg), ShouldEqual, 2) + So(len(cfg), ShouldEqual, 3) dsCfg := cfg[0] - ds := dsCfg.Datasources[0] - So(ds.Name, ShouldEqual, "name") - So(ds.Type, ShouldEqual, "type") - So(ds.Access, ShouldEqual, models.DS_ACCESS_PROXY) - So(ds.OrgId, ShouldEqual, 2) - So(ds.Url, ShouldEqual, "url") - So(ds.User, ShouldEqual, "user") - So(ds.Password, ShouldEqual, "password") - So(ds.Database, ShouldEqual, "database") - So(ds.BasicAuth, ShouldBeTrue) - So(ds.BasicAuthUser, ShouldEqual, "basic_auth_user") - So(ds.BasicAuthPassword, ShouldEqual, "basic_auth_password") - So(ds.WithCredentials, ShouldBeTrue) - So(ds.IsDefault, ShouldBeTrue) - So(ds.Editable, ShouldBeTrue) + So(dsCfg.ApiVersion, ShouldEqual, 1) - So(len(ds.JsonData), ShouldBeGreaterThan, 2) - So(ds.JsonData["graphiteVersion"], ShouldEqual, "1.1") - So(ds.JsonData["tlsAuth"], ShouldEqual, true) - So(ds.JsonData["tlsAuthWithCACert"], ShouldEqual, true) + validateDatasource(dsCfg) + validateDeleteDatasources(dsCfg) - So(len(ds.SecureJsonData), ShouldBeGreaterThan, 2) - So(ds.SecureJsonData["tlsCACert"], ShouldEqual, "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==") - So(ds.SecureJsonData["tlsClientCert"], ShouldEqual, "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==") - So(ds.SecureJsonData["tlsClientKey"], ShouldEqual, "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==") + dsCount := 0 + delDsCount := 0 - dstwo := cfg[1].Datasources[0] - So(dstwo.Name, ShouldEqual, "name2") + for _, c := range cfg { + dsCount += len(c.Datasources) + delDsCount += len(c.DeleteDatasources) + } + + So(dsCount, ShouldEqual, 2) + So(delDsCount, ShouldEqual, 1) + }) + + Convey("can read all properties from version 0", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig(versionZero) + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 1) + + dsCfg := cfg[0] + + So(dsCfg.ApiVersion, ShouldEqual, 0) + + validateDatasource(dsCfg) + validateDeleteDatasources(dsCfg) }) }) } +func validateDeleteDatasources(dsCfg *DatasourcesAsConfig) { + So(len(dsCfg.DeleteDatasources), ShouldEqual, 1) + deleteDs := dsCfg.DeleteDatasources[0] + So(deleteDs.Name, ShouldEqual, "old-graphite3") + So(deleteDs.OrgId, ShouldEqual, 2) +} +func validateDatasource(dsCfg *DatasourcesAsConfig) { + ds := dsCfg.Datasources[0] + So(ds.Name, ShouldEqual, "name") + So(ds.Type, ShouldEqual, "type") + So(ds.Access, ShouldEqual, models.DS_ACCESS_PROXY) + So(ds.OrgId, ShouldEqual, 2) + So(ds.Url, ShouldEqual, "url") + So(ds.User, ShouldEqual, "user") + So(ds.Password, ShouldEqual, "password") + So(ds.Database, ShouldEqual, "database") + So(ds.BasicAuth, ShouldBeTrue) + So(ds.BasicAuthUser, ShouldEqual, "basic_auth_user") + So(ds.BasicAuthPassword, ShouldEqual, "basic_auth_password") + So(ds.WithCredentials, ShouldBeTrue) + So(ds.IsDefault, ShouldBeTrue) + So(ds.Editable, ShouldBeTrue) + So(ds.Version, ShouldEqual, 10) + + So(len(ds.JsonData), ShouldBeGreaterThan, 2) + So(ds.JsonData["graphiteVersion"], ShouldEqual, "1.1") + So(ds.JsonData["tlsAuth"], ShouldEqual, true) + So(ds.JsonData["tlsAuthWithCACert"], ShouldEqual, true) + + So(len(ds.SecureJsonData), ShouldBeGreaterThan, 2) + So(ds.SecureJsonData["tlsCACert"], ShouldEqual, "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==") + So(ds.SecureJsonData["tlsClientCert"], ShouldEqual, "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==") + So(ds.SecureJsonData["tlsClientKey"], ShouldEqual, "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==") +} type fakeRepository struct { inserted []*models.AddDataSourceCommand diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index aa1308ffa29..1fa0a3b3173 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -2,16 +2,12 @@ package datasources import ( "errors" - "io/ioutil" - "path/filepath" - "strings" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" - yaml "gopkg.in/yaml.v2" ) var ( @@ -94,65 +90,3 @@ func (dc *DatasourceProvisioner) deleteDatasources(dsToDelete []*DeleteDatasourc return nil } - -type configReader struct { - log log.Logger -} - -func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { - var datasources []*DatasourcesAsConfig - - files, err := ioutil.ReadDir(path) - if err != nil { - cr.log.Error("cant read datasource provisioning files from directory", "path", path) - return datasources, nil - } - - for _, file := range files { - if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { - filename, _ := filepath.Abs(filepath.Join(path, file.Name())) - yamlFile, err := ioutil.ReadFile(filename) - - if err != nil { - return nil, err - } - var datasource *DatasourcesAsConfig - err = yaml.Unmarshal(yamlFile, &datasource) - if err != nil { - return nil, err - } - - if datasource != nil { - datasources = append(datasources, datasource) - } - } - } - - defaultCount := 0 - for i := range datasources { - if datasources[i].Datasources == nil { - continue - } - - for _, ds := range datasources[i].Datasources { - if ds.OrgId == 0 { - ds.OrgId = 1 - } - - if ds.IsDefault { - defaultCount++ - if defaultCount > 1 { - return nil, ErrInvalidConfigToManyDefault - } - } - } - - for _, ds := range datasources[i].DeleteDatasources { - if ds.OrgId == 0 { - ds.OrgId = 1 - } - } - } - - return datasources, nil -} diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml index af0d3009a4c..b92b81f7079 100644 --- a/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml @@ -1,23 +1,30 @@ +apiVersion: 1 + datasources: - name: name type: type access: proxy - org_id: 2 + orgId: 2 url: url password: password user: user database: database - basic_auth: true - basic_auth_user: basic_auth_user - basic_auth_password: basic_auth_password - with_credentials: true - is_default: true - json_data: + basicAuth: true + basicAuthUser: basic_auth_user + basicAuthPassword: basic_auth_password + withCredentials: true + isDefault: true + jsonData: graphiteVersion: "1.1" tlsAuth: true tlsAuthWithCACert: true - secure_json_data: + secureJsonData: tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" editable: true + version: 10 + +deleteDatasources: + - name: old-graphite3 + orgId: 2 diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml new file mode 100644 index 00000000000..2187eabdc46 --- /dev/null +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml @@ -0,0 +1,32 @@ +# Should not be included + + +apiVersion: 1 + +#datasources: +# - name: name +# type: type +# access: proxy +# orgId: 2 +# url: url +# password: password +# user: user +# database: database +# basicAuth: true +# basicAuthUser: basic_auth_user +# basicAuthPassword: basic_auth_password +# withCredentials: true +# jsonData: +# graphiteVersion: "1.1" +# tlsAuth: true +# tlsAuthWithCACert: true +# secureJsonData: +# tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" +# tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" +# tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" +# editable: true +# version: 10 +# +#deleteDatasources: +# - name: old-graphite3 +# orgId: 2 diff --git a/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml b/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml index 43c41ee9b3b..9f27a8d07ee 100644 --- a/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml +++ b/pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml @@ -3,5 +3,5 @@ datasources: - name: name2 type: type2 access: proxy - org_id: 2 + orgId: 2 url: url2 diff --git a/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml b/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml new file mode 100644 index 00000000000..fcd4ddd6b01 --- /dev/null +++ b/pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml @@ -0,0 +1,28 @@ +datasources: + - name: name + type: type + access: proxy + org_id: 2 + url: url + password: password + user: user + database: database + basic_auth: true + basic_auth_user: basic_auth_user + basic_auth_password: basic_auth_password + with_credentials: true + is_default: true + json_data: + graphiteVersion: "1.1" + tlsAuth: true + tlsAuthWithCACert: true + secure_json_data: + tlsCACert: "MjNOcW9RdkbUDHZmpco2HCYzVq9dE+i6Yi+gmUJotq5CDA==" + tlsClientCert: "ckN0dGlyMXN503YNfjTcf9CV+GGQneN+xmAclQ==" + tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" + editable: true + version: 10 + +delete_datasources: + - name: old-graphite3 + org_id: 2 diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index ee2175d6a90..8e2443a0169 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -1,22 +1,74 @@ package datasources -import "github.com/grafana/grafana/pkg/models" +import ( + "github.com/grafana/grafana/pkg/models" +) import "github.com/grafana/grafana/pkg/components/simplejson" +type ConfigVersion struct { + ApiVersion int64 `json:"apiVersion" yaml:"apiVersion"` +} + type DatasourcesAsConfig struct { - Datasources []*DataSourceFromConfig `json:"datasources" yaml:"datasources"` - DeleteDatasources []*DeleteDatasourceConfig `json:"delete_datasources" yaml:"delete_datasources"` + ApiVersion int64 + + Datasources []*DataSourceFromConfig + DeleteDatasources []*DeleteDatasourceConfig } type DeleteDatasourceConfig struct { + OrgId int64 + Name string +} + +type DataSourceFromConfig struct { + OrgId int64 + Version int + + Name string + Type string + Access string + Url string + Password string + User string + Database string + BasicAuth bool + BasicAuthUser string + BasicAuthPassword string + WithCredentials bool + IsDefault bool + JsonData map[string]interface{} + SecureJsonData map[string]string + Editable bool +} + +type DatasourcesAsConfigV0 struct { + ConfigVersion + + Datasources []*DataSourceFromConfigV0 `json:"datasources" yaml:"datasources"` + DeleteDatasources []*DeleteDatasourceConfigV0 `json:"delete_datasources" yaml:"delete_datasources"` +} + +type DatasourcesAsConfigV1 struct { + ConfigVersion + + Datasources []*DataSourceFromConfigV1 `json:"datasources" yaml:"datasources"` + DeleteDatasources []*DeleteDatasourceConfigV1 `json:"deleteDatasources" yaml:"deleteDatasources"` +} + +type DeleteDatasourceConfigV0 struct { OrgId int64 `json:"org_id" yaml:"org_id"` Name string `json:"name" yaml:"name"` } -type DataSourceFromConfig struct { - OrgId int64 `json:"org_id" yaml:"org_id"` - Version int `json:"version" yaml:"version"` +type DeleteDatasourceConfigV1 struct { + OrgId int64 `json:"orgId" yaml:"orgId"` + Name string `json:"name" yaml:"name"` +} +type DataSourceFromConfigV0 struct { + OrgId int64 `json:"org_id" yaml:"org_id"` + Version int `json:"version" yaml:"version"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` Access string `json:"access" yaml:"access"` @@ -34,6 +86,108 @@ type DataSourceFromConfig struct { Editable bool `json:"editable" yaml:"editable"` } +type DataSourceFromConfigV1 struct { + OrgId int64 `json:"orgId" yaml:"orgId"` + Version int `json:"version" yaml:"version"` + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Access string `json:"access" yaml:"access"` + Url string `json:"url" yaml:"url"` + Password string `json:"password" yaml:"password"` + User string `json:"user" yaml:"user"` + Database string `json:"database" yaml:"database"` + BasicAuth bool `json:"basicAuth" yaml:"basicAuth"` + BasicAuthUser string `json:"basicAuthUser" yaml:"basicAuthUser"` + BasicAuthPassword string `json:"basicAuthPassword" yaml:"basicAuthPassword"` + WithCredentials bool `json:"withCredentials" yaml:"withCredentials"` + IsDefault bool `json:"isDefault" yaml:"isDefault"` + JsonData map[string]interface{} `json:"jsonData" yaml:"jsonData"` + SecureJsonData map[string]string `json:"secureJsonData" yaml:"secureJsonData"` + Editable bool `json:"editable" yaml:"editable"` +} + +func (cfg *DatasourcesAsConfigV1) mapToDatasourceFromConfig(apiVersion int64) *DatasourcesAsConfig { + r := &DatasourcesAsConfig{} + + r.ApiVersion = apiVersion + + if cfg == nil { + return r + } + + for _, ds := range cfg.Datasources { + r.Datasources = append(r.Datasources, &DataSourceFromConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + Type: ds.Type, + Access: ds.Access, + Url: ds.Url, + Password: ds.Password, + User: ds.User, + Database: ds.Database, + BasicAuth: ds.BasicAuth, + BasicAuthUser: ds.BasicAuthUser, + BasicAuthPassword: ds.BasicAuthPassword, + WithCredentials: ds.WithCredentials, + IsDefault: ds.IsDefault, + JsonData: ds.JsonData, + SecureJsonData: ds.SecureJsonData, + Editable: ds.Editable, + Version: ds.Version, + }) + } + + for _, ds := range cfg.DeleteDatasources { + r.DeleteDatasources = append(r.DeleteDatasources, &DeleteDatasourceConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + }) + } + + return r +} + +func (cfg *DatasourcesAsConfigV0) mapToDatasourceFromConfig(apiVersion int64) *DatasourcesAsConfig { + r := &DatasourcesAsConfig{} + + r.ApiVersion = apiVersion + + if cfg == nil { + return r + } + + for _, ds := range cfg.Datasources { + r.Datasources = append(r.Datasources, &DataSourceFromConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + Type: ds.Type, + Access: ds.Access, + Url: ds.Url, + Password: ds.Password, + User: ds.User, + Database: ds.Database, + BasicAuth: ds.BasicAuth, + BasicAuthUser: ds.BasicAuthUser, + BasicAuthPassword: ds.BasicAuthPassword, + WithCredentials: ds.WithCredentials, + IsDefault: ds.IsDefault, + JsonData: ds.JsonData, + SecureJsonData: ds.SecureJsonData, + Editable: ds.Editable, + Version: ds.Version, + }) + } + + for _, ds := range cfg.DeleteDatasources { + r.DeleteDatasources = append(r.DeleteDatasources, &DeleteDatasourceConfig{ + OrgId: ds.OrgId, + Name: ds.Name, + }) + } + + return r +} + func createInsertCommand(ds *DataSourceFromConfig) *models.AddDataSourceCommand { jsonData := simplejson.New() if len(ds.JsonData) > 0 { diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go new file mode 100644 index 00000000000..2ec399437e6 --- /dev/null +++ b/pkg/services/quota/quota.go @@ -0,0 +1,87 @@ +package quota + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/session" + "github.com/grafana/grafana/pkg/setting" +) + +func QuotaReached(c *m.ReqContext, target string) (bool, error) { + if !setting.Quota.Enabled { + return false, nil + } + + // get the list of scopes that this target is valid for. Org, User, Global + scopes, err := m.GetQuotaScopes(target) + if err != nil { + return false, err + } + + for _, scope := range scopes { + c.Logger.Debug("Checking quota", "target", target, "scope", scope) + + switch scope.Name { + case "global": + if scope.DefaultLimit < 0 { + continue + } + if scope.DefaultLimit == 0 { + return true, nil + } + if target == "session" { + usedSessions := session.GetSessionCount() + if int64(usedSessions) > scope.DefaultLimit { + c.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) + return true, nil + } + continue + } + query := m.GetGlobalQuotaByTargetQuery{Target: scope.Target} + if err := bus.Dispatch(&query); err != nil { + return true, err + } + if query.Result.Used >= scope.DefaultLimit { + return true, nil + } + case "org": + if !c.IsSignedIn { + continue + } + query := m.GetOrgQuotaByTargetQuery{OrgId: c.OrgId, Target: scope.Target, Default: scope.DefaultLimit} + if err := bus.Dispatch(&query); err != nil { + return true, err + } + if query.Result.Limit < 0 { + continue + } + if query.Result.Limit == 0 { + return true, nil + } + + if query.Result.Used >= query.Result.Limit { + return true, nil + } + case "user": + if !c.IsSignedIn || c.UserId == 0 { + continue + } + query := m.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit} + if err := bus.Dispatch(&query); err != nil { + return true, err + } + if query.Result.Limit < 0 { + continue + } + if query.Result.Limit == 0 { + return true, nil + } + + if query.Result.Used >= query.Result.Limit { + return true, nil + } + } + } + + return false, nil +} diff --git a/vendor/github.com/go-macaron/session/mysql/mysql.go b/pkg/services/session/mysql.go similarity index 87% rename from vendor/github.com/go-macaron/session/mysql/mysql.go rename to pkg/services/session/mysql.go index a16db779c63..f8c5d828cfa 100644 --- a/vendor/github.com/go-macaron/session/mysql/mysql.go +++ b/pkg/services/session/mysql.go @@ -108,6 +108,7 @@ func (p *MysqlProvider) Init(expire int64, connStr string) (err error) { p.expire = expire p.c, err = sql.Open("mysql", connStr) + p.c.SetConnMaxLifetime(time.Second * time.Duration(sessionConnMaxLifetime)) if err != nil { return err } @@ -141,12 +142,29 @@ func (p *MysqlProvider) Read(sid string) (session.RawStore, error) { // Exist returns true if session with given ID exists. func (p *MysqlProvider) Exist(sid string) bool { + exists, err := p.queryExists(sid) + + if err != nil { + exists, err = p.queryExists(sid) + } + + if err != nil { + log.Printf("session/mysql: error checking if session exists: %v", err) + return false + } + + return exists +} + +func (p *MysqlProvider) queryExists(sid string) (bool, error) { var data []byte err := p.c.QueryRow("SELECT data FROM session WHERE `key`=?", sid).Scan(&data) + if err != nil && err != sql.ErrNoRows { - panic("session/mysql: error checking existence: " + err.Error()) + return false, err } - return err != sql.ErrNoRows + + return err != sql.ErrNoRows, nil } // Destory deletes a session by session ID. @@ -185,7 +203,12 @@ func (p *MysqlProvider) Count() (total int) { // GC calls GC to clean expired sessions. func (p *MysqlProvider) GC() { - if _, err := p.c.Exec("DELETE FROM session WHERE expiry + ? <= UNIX_TIMESTAMP(NOW())", p.expire); err != nil { + var err error + if _, err = p.c.Exec("DELETE FROM session WHERE expiry + ? <= UNIX_TIMESTAMP(NOW())", p.expire); err != nil { + _, err = p.c.Exec("DELETE FROM session WHERE expiry + ? <= UNIX_TIMESTAMP(NOW())", p.expire) + } + + if err != nil { log.Printf("session/mysql: error garbage collecting: %v", err) } } diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go new file mode 100644 index 00000000000..5873a6a5b72 --- /dev/null +++ b/pkg/services/session/session.go @@ -0,0 +1,175 @@ +package session + +import ( + "math/rand" + "time" + + ms "github.com/go-macaron/session" + _ "github.com/go-macaron/session/memcache" + _ "github.com/go-macaron/session/postgres" + _ "github.com/go-macaron/session/redis" + "github.com/grafana/grafana/pkg/log" + "gopkg.in/macaron.v1" +) + +const ( + SESS_KEY_USERID = "uid" + SESS_KEY_OAUTH_STATE = "state" + SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys + SESS_KEY_LASTLDAPSYNC = "last_ldap_sync" +) + +var sessionManager *ms.Manager +var sessionOptions *ms.Options +var StartSessionGC func() +var GetSessionCount func() int +var sessionLogger = log.New("session") +var sessionConnMaxLifetime int64 + +func init() { + StartSessionGC = func() { + sessionManager.GC() + sessionLogger.Debug("Session GC") + time.AfterFunc(time.Duration(sessionOptions.Gclifetime)*time.Second, StartSessionGC) + } + GetSessionCount = func() int { + return sessionManager.Count() + } +} + +func Init(options *ms.Options, connMaxLifetime int64) { + var err error + sessionOptions = prepareOptions(options) + sessionConnMaxLifetime = connMaxLifetime + sessionManager, err = ms.NewManager(options.Provider, *options) + if err != nil { + panic(err) + } + + // start GC threads after some random seconds + rndSeconds := 10 + rand.Int63n(180) + time.AfterFunc(time.Duration(rndSeconds)*time.Second, StartSessionGC) +} + +func prepareOptions(opt *ms.Options) *ms.Options { + if len(opt.Provider) == 0 { + opt.Provider = "memory" + } + if len(opt.ProviderConfig) == 0 { + opt.ProviderConfig = "data/sessions" + } + if len(opt.CookieName) == 0 { + opt.CookieName = "grafana_sess" + } + if len(opt.CookiePath) == 0 { + opt.CookiePath = "/" + } + if opt.Gclifetime == 0 { + opt.Gclifetime = 3600 + } + if opt.Maxlifetime == 0 { + opt.Maxlifetime = opt.Gclifetime + } + if opt.IDLength == 0 { + opt.IDLength = 16 + } + + return opt +} + +func GetSession() SessionStore { + return &SessionWrapper{manager: sessionManager} +} + +type SessionStore interface { + // Set sets value to given key in session. + Set(interface{}, interface{}) error + // Get gets value by given key in session. + Get(interface{}) interface{} + // Delete deletes a key from session. + Delete(interface{}) interface{} + // ID returns current session ID. + ID() string + // Release releases session resource and save data to provider. + Release() error + // Destory deletes a session. + Destory(*macaron.Context) error + // init + Start(*macaron.Context) error + // RegenerateId regenerates the session id + RegenerateId(*macaron.Context) error +} + +type SessionWrapper struct { + session ms.RawStore + manager *ms.Manager +} + +func (s *SessionWrapper) Start(c *macaron.Context) error { + // See https://github.com/grafana/grafana/issues/11155 for details on why + // a recover and retry is needed + defer func() error { + if err := recover(); err != nil { + var retryErr error + s.session, retryErr = s.manager.Start(c) + return retryErr + } + + return nil + }() + + var err error + s.session, err = s.manager.Start(c) + return err +} + +func (s *SessionWrapper) RegenerateId(c *macaron.Context) error { + var err error + s.session, err = s.manager.RegenerateId(c) + return err +} + +func (s *SessionWrapper) Set(k interface{}, v interface{}) error { + if s.session != nil { + return s.session.Set(k, v) + } + return nil +} + +func (s *SessionWrapper) Get(k interface{}) interface{} { + if s.session != nil { + return s.session.Get(k) + } + return nil +} + +func (s *SessionWrapper) Delete(k interface{}) interface{} { + if s.session != nil { + return s.session.Delete(k) + } + return nil +} + +func (s *SessionWrapper) ID() string { + if s.session != nil { + return s.session.ID() + } + return "" +} + +func (s *SessionWrapper) Release() error { + if s.session != nil { + return s.session.Release() + } + return nil +} + +func (s *SessionWrapper) Destory(c *macaron.Context) error { + if s.session != nil { + if err := s.manager.Destory(c); err != nil { + return err + } + s.session = nil + } + return nil +} diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 96af8bc49ee..e99367e4d6f 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -10,6 +10,9 @@ import ( m "github.com/grafana/grafana/pkg/models" ) +// timeNow makes it possible to test usage of time +var timeNow = time.Now + func init() { bus.AddHandler("sql", SaveAlerts) bus.AddHandler("sql", HandleAlertsQuery) @@ -61,52 +64,61 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro } func HandleAlertsQuery(query *m.GetAlertsQuery) error { - var sql bytes.Buffer - params := make([]interface{}, 0) + builder := SqlBuilder{} - sql.WriteString(`SELECT * - from alert - `) + builder.Write(`SELECT + alert.id, + alert.dashboard_id, + alert.panel_id, + alert.name, + alert.state, + alert.new_state_date, + alert.eval_date, + alert.execution_error, + dashboard.uid as dashboard_uid, + dashboard.slug as dashboard_slug + FROM alert + INNER JOIN dashboard on dashboard.id = alert.dashboard_id `) - sql.WriteString(`WHERE org_id = ?`) - params = append(params, query.OrgId) + builder.Write(`WHERE alert.org_id = ?`, query.OrgId) if query.DashboardId != 0 { - sql.WriteString(` AND dashboard_id = ?`) - params = append(params, query.DashboardId) + builder.Write(` AND alert.dashboard_id = ?`, query.DashboardId) } if query.PanelId != 0 { - sql.WriteString(` AND panel_id = ?`) - params = append(params, query.PanelId) + builder.Write(` AND alert.panel_id = ?`, query.PanelId) } if len(query.State) > 0 && query.State[0] != "all" { - sql.WriteString(` AND (`) + builder.Write(` AND (`) for i, v := range query.State { if i > 0 { - sql.WriteString(" OR ") + builder.Write(" OR ") } if strings.HasPrefix(v, "not_") { - sql.WriteString("state <> ? ") + builder.Write("state <> ? ") v = strings.TrimPrefix(v, "not_") } else { - sql.WriteString("state = ? ") + builder.Write("state = ? ") } - params = append(params, v) + builder.AddParams(v) } - sql.WriteString(")") + builder.Write(")") } - sql.WriteString(" ORDER BY name ASC") + if query.User.OrgRole != m.ROLE_ADMIN { + builder.writeDashboardPermissionFilter(query.User, m.PERMISSION_EDIT) + } + + builder.Write(" ORDER BY name ASC") if query.Limit != 0 { - sql.WriteString(" LIMIT ?") - params = append(params, query.Limit) + builder.Write(" LIMIT ?", query.Limit) } - alerts := make([]*m.Alert, 0) - if err := x.SQL(sql.String(), params...).Find(&alerts); err != nil { + alerts := make([]*m.AlertListItemDTO, 0) + if err := x.SQL(builder.GetSqlString(), builder.params...).Find(&alerts); err != nil { return err } @@ -120,7 +132,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { return nil } -func DeleteAlertDefinition(dashboardId int64, sess *DBSession) error { +func deleteAlertDefinition(dashboardId int64, sess *DBSession) error { alerts := make([]*m.Alert, 0) sess.Where("dashboard_id = ?", dashboardId).Find(&alerts) @@ -138,7 +150,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error { return err } - if err := upsertAlerts(existingAlerts, cmd, sess); err != nil { + if err := updateAlerts(existingAlerts, cmd, sess); err != nil { return err } @@ -150,7 +162,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error { }) } -func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error { +func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error { for _, alert := range cmd.Alerts { update := false var alertToUpdate *m.Alert @@ -166,7 +178,7 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS if update { if alertToUpdate.ContainsUpdates(alert) { - alert.Updated = time.Now() + alert.Updated = timeNow() alert.State = alertToUpdate.State sess.MustCols("message") _, err := sess.Id(alert.Id).Update(alert) @@ -177,10 +189,10 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS sqlog.Debug("Alert updated", "name", alert.Name, "id", alert.Id) } } else { - alert.Updated = time.Now() - alert.Created = time.Now() + alert.Updated = timeNow() + alert.Created = timeNow() alert.State = m.AlertStatePending - alert.NewStateDate = time.Now() + alert.NewStateDate = timeNow() _, err := sess.Insert(alert) if err != nil { @@ -243,8 +255,8 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error { } alert.State = cmd.State - alert.StateChanges += 1 - alert.NewStateDate = time.Now() + alert.StateChanges++ + alert.NewStateDate = timeNow() alert.EvalData = cmd.EvalData if cmd.Error == "" { @@ -267,11 +279,13 @@ func PauseAlert(cmd *m.PauseAlertCommand) error { var buffer bytes.Buffer params := make([]interface{}, 0) - buffer.WriteString(`UPDATE alert SET state = ?`) + buffer.WriteString(`UPDATE alert SET state = ?, new_state_date = ?`) if cmd.Paused { params = append(params, string(m.AlertStatePaused)) + params = append(params, timeNow()) } else { params = append(params, string(m.AlertStatePending)) + params = append(params, timeNow()) } buffer.WriteString(` WHERE id IN (?` + strings.Repeat(",?", len(cmd.AlertIds)-1) + `)`) @@ -297,7 +311,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error { newState = string(m.AlertStatePending) } - res, err := sess.Exec(`UPDATE alert SET state = ?`, newState) + res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow()) if err != nil { return err } diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index 7b27f5b9ca4..296d16c2f45 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -6,9 +6,26 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" + "time" ) +func mockTimeNow() { + var timeSeed int64 + timeNow = func() time.Time { + fakeNow := time.Unix(timeSeed, 0) + timeSeed += 1 + return fakeNow + } +} + +func resetTimeNow() { + timeNow = time.Now +} + func TestAlertingDataAccess(t *testing.T) { + mockTimeNow() + defer resetTimeNow() + Convey("Testing Alerting data access", t, func() { InitTestDB(t) @@ -50,13 +67,11 @@ func TestAlertingDataAccess(t *testing.T) { So(err, ShouldBeNil) }) - Convey("can pause alert", func() { - cmd := &m.PauseAllAlertCommand{ - Paused: true, - } + alert, _ := getAlertById(1) + stateDateBeforePause := alert.NewStateDate - err = PauseAllAlerts(cmd) - So(err, ShouldBeNil) + Convey("can pause all alerts", func() { + pauseAllAlerts(true) Convey("cannot updated paused alert", func() { cmd := &m.SetAlertStateCommand{ @@ -67,19 +82,38 @@ func TestAlertingDataAccess(t *testing.T) { err = SetAlertState(cmd) So(err, ShouldNotBeNil) }) + + Convey("pausing alerts should update their NewStateDate", func() { + alert, _ = getAlertById(1) + stateDateAfterPause := alert.NewStateDate + So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause) + }) + + Convey("unpausing alerts should update their NewStateDate again", func() { + pauseAllAlerts(false) + alert, _ = getAlertById(1) + stateDateAfterUnpause := alert.NewStateDate + So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterUnpause) + }) }) }) Convey("Can read properties", func() { - alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1} + alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&alertQuery) alert := alertQuery.Result[0] So(err2, ShouldBeNil) So(alert.Name, ShouldEqual, "Alerting title") - So(alert.Message, ShouldEqual, "Alerting message") So(alert.State, ShouldEqual, "pending") - So(alert.Frequency, ShouldEqual, 1) + }) + + Convey("Viewer cannot read alerts", func() { + alertQuery := m.GetAlertsQuery{DashboardId: testDash.Id, PanelId: 1, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_VIEWER}} + err2 := HandleAlertsQuery(&alertQuery) + + So(err2, ShouldBeNil) + So(alertQuery.Result, ShouldHaveLength, 0) }) Convey("Alerts with same dashboard id and panel id should update", func() { @@ -100,7 +134,7 @@ func TestAlertingDataAccess(t *testing.T) { }) Convey("Alerts should be updated", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(err2, ShouldBeNil) @@ -149,7 +183,7 @@ func TestAlertingDataAccess(t *testing.T) { Convey("Should save 3 dashboards", func() { So(err, ShouldBeNil) - queryForDashboard := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + queryForDashboard := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&queryForDashboard) So(err2, ShouldBeNil) @@ -163,7 +197,7 @@ func TestAlertingDataAccess(t *testing.T) { err = SaveAlerts(&cmd) Convey("should delete the missing alert", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(err2, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) @@ -198,7 +232,7 @@ func TestAlertingDataAccess(t *testing.T) { So(err, ShouldBeNil) Convey("Alerts should be removed", func() { - query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1} + query := m.GetAlertsQuery{DashboardId: testDash.Id, OrgId: 1, User: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}} err2 := HandleAlertsQuery(&query) So(testDash.Id, ShouldEqual, 1) @@ -208,3 +242,90 @@ func TestAlertingDataAccess(t *testing.T) { }) }) } + +func TestPausingAlerts(t *testing.T) { + mockTimeNow() + defer resetTimeNow() + + Convey("Given an alert", t, func() { + InitTestDB(t) + + testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert") + alert, _ := insertTestAlert("Alerting title", "Alerting message", testDash.OrgId, testDash.Id, simplejson.New()) + + stateDateBeforePause := alert.NewStateDate + stateDateAfterPause := stateDateBeforePause + Convey("when paused", func() { + pauseAlert(testDash.OrgId, 1, true) + + Convey("the NewStateDate should be updated", func() { + alert, _ := getAlertById(1) + + stateDateAfterPause = alert.NewStateDate + So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause) + }) + }) + + Convey("when unpaused", func() { + pauseAlert(testDash.OrgId, 1, false) + + Convey("the NewStateDate should be updated again", func() { + alert, _ := getAlertById(1) + + stateDateAfterUnpause := alert.NewStateDate + So(stateDateAfterPause, ShouldHappenBefore, stateDateAfterUnpause) + }) + }) + }) +} +func pauseAlert(orgId int64, alertId int64, pauseState bool) (int64, error) { + cmd := &m.PauseAlertCommand{ + OrgId: orgId, + AlertIds: []int64{alertId}, + Paused: pauseState, + } + err := PauseAlert(cmd) + So(err, ShouldBeNil) + return cmd.ResultCount, err +} +func insertTestAlert(title string, message string, orgId int64, dashId int64, settings *simplejson.Json) (*m.Alert, error) { + items := []*m.Alert{ + { + PanelId: 1, + DashboardId: dashId, + OrgId: orgId, + Name: title, + Message: message, + Settings: settings, + Frequency: 1, + }, + } + + cmd := m.SaveAlertsCommand{ + Alerts: items, + DashboardId: dashId, + OrgId: orgId, + UserId: 1, + } + + err := SaveAlerts(&cmd) + return cmd.Alerts[0], err +} + +func getAlertById(id int64) (*m.Alert, error) { + q := &m.GetAlertByIdQuery{ + Id: id, + } + err := GetAlertById(q) + So(err, ShouldBeNil) + return q.Result, err +} + +func pauseAllAlerts(pauseState bool) error { + cmd := &m.PauseAllAlertCommand{ + Paused: pauseState, + } + err := PauseAllAlerts(cmd) + So(err, ShouldBeNil) + return err +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 42c83da8810..8a89c3d942c 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -23,6 +23,7 @@ func init() { bus.AddHandler("sql", GetDashboardsByPluginId) bus.AddHandler("sql", GetDashboardPermissionsForUser) bus.AddHandler("sql", GetDashboardsBySlug) + bus.AddHandler("sql", ValidateDashboardBeforeSave) } var generateNewUid func() string = util.GenerateShortUid @@ -36,38 +37,35 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { dash := cmd.GetDashboardModel() - if err := getExistingDashboardForUpdate(sess, dash, cmd); err != nil { - return err + userId := cmd.UserId + + if userId == 0 { + userId = -1 } - var existingByTitleAndFolder m.Dashboard - - dashWithTitleAndFolderExists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existingByTitleAndFolder) - if err != nil { - return err - } - - if dashWithTitleAndFolderExists { - if dash.Id != existingByTitleAndFolder.Id { - if existingByTitleAndFolder.IsFolder && !cmd.IsFolder { - return m.ErrDashboardWithSameNameAsFolder - } - - if !existingByTitleAndFolder.IsFolder && cmd.IsFolder { - return m.ErrDashboardFolderWithSameNameAsDashboard - } + if dash.Id > 0 { + var existing m.Dashboard + dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) + if err != nil { + return err + } + if !dashWithIdExists { + return m.ErrDashboardNotFound + } + // check for is someone else has written in between + if dash.Version != existing.Version { if cmd.Overwrite { - dash.Id = existingByTitleAndFolder.Id - dash.Version = existingByTitleAndFolder.Version - - if dash.Uid == "" { - dash.Uid = existingByTitleAndFolder.Uid - } + dash.SetVersion(existing.Version) } else { - return m.ErrDashboardWithSameNameInFolderExists + return m.ErrDashboardVersionMismatch } } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && cmd.Overwrite == false { + return m.UpdatePluginDashboardError{PluginId: existing.PluginId} + } } if dash.Uid == "" { @@ -75,26 +73,32 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { if err != nil { return err } - dash.Uid = uid - dash.Data.Set("uid", uid) + dash.SetUid(uid) } parentVersion := dash.Version affectedRows := int64(0) + var err error if dash.Id == 0 { - dash.Version = 1 + dash.SetVersion(1) + dash.Created = time.Now() + dash.CreatedBy = userId + dash.Updated = time.Now() + dash.UpdatedBy = userId metrics.M_Api_Dashboard_Insert.Inc() - dash.Data.Set("version", dash.Version) affectedRows, err = sess.Insert(dash) } else { - dash.Version++ - dash.Data.Set("version", dash.Version) + dash.SetVersion(dash.Version + 1) if !cmd.UpdatedAt.IsZero() { dash.Updated = cmd.UpdatedAt + } else { + dash.Updated = time.Now() } + dash.UpdatedBy = userId + affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) } @@ -145,72 +149,6 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { return err } -func getExistingDashboardForUpdate(sess *DBSession, dash *m.Dashboard, cmd *m.SaveDashboardCommand) (err error) { - dashWithIdExists := false - var existingById m.Dashboard - - if dash.Id > 0 { - dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) - if err != nil { - return err - } - - if !dashWithIdExists { - return m.ErrDashboardNotFound - } - - if dash.Uid == "" { - dash.Uid = existingById.Uid - } - } - - dashWithUidExists := false - var existingByUid m.Dashboard - - if dash.Uid != "" { - dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) - if err != nil { - return err - } - } - - if !dashWithIdExists && !dashWithUidExists { - return nil - } - - if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { - return m.ErrDashboardWithSameUIDExists - } - - existing := existingById - - if !dashWithIdExists && dashWithUidExists { - dash.Id = existingByUid.Id - existing = existingByUid - } - - if (existing.IsFolder && !cmd.IsFolder) || - (!existing.IsFolder && cmd.IsFolder) { - return m.ErrDashboardTypeMismatch - } - - // check for is someone else has written in between - if dash.Version != existing.Version { - if cmd.Overwrite { - dash.Version = existing.Version - } else { - return m.ErrDashboardVersionMismatch - } - } - - // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && cmd.Overwrite == false { - return m.UpdatePluginDashboardError{PluginId: existing.PluginId} - } - - return nil -} - func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { for i := 0; i < 3; i++ { uid := generateNewUid() @@ -238,8 +176,8 @@ func GetDashboard(query *m.GetDashboardQuery) error { return m.ErrDashboardNotFound } - dashboard.Data.Set("id", dashboard.Id) - dashboard.Data.Set("uid", dashboard.Uid) + dashboard.SetId(dashboard.Id) + dashboard.SetUid(dashboard.Uid) query.Result = &dashboard return nil } @@ -392,7 +330,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { } } - if err := DeleteAlertDefinition(dashboard.Id, sess); err != nil { + if err := deleteAlertDefinition(dashboard.Id, sess); err != nil { return nil } @@ -548,3 +486,128 @@ func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error { query.Result = us return nil } + +func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDashboardBeforeSaveCommand) (err error) { + dash := cmd.Dashboard + + dashWithIdExists := false + var existingById m.Dashboard + + if dash.Id > 0 { + dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) + if err != nil { + return err + } + + if !dashWithIdExists { + return m.ErrDashboardNotFound + } + + if dash.Uid == "" { + dash.SetUid(existingById.Uid) + } + } + + dashWithUidExists := false + var existingByUid m.Dashboard + + if dash.Uid != "" { + dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) + if err != nil { + return err + } + } + + if dash.FolderId > 0 { + var existingFolder m.Dashboard + folderExists, folderErr := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId, dialect.BooleanStr(true)).Get(&existingFolder) + if folderErr != nil { + return folderErr + } + + if !folderExists { + return m.ErrDashboardFolderNotFound + } + } + + if !dashWithIdExists && !dashWithUidExists { + return nil + } + + if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { + return m.ErrDashboardWithSameUIDExists + } + + existing := existingById + + if !dashWithIdExists && dashWithUidExists { + dash.SetId(existingByUid.Id) + dash.SetUid(existingByUid.Uid) + existing = existingByUid + } + + if (existing.IsFolder && !dash.IsFolder) || + (!existing.IsFolder && dash.IsFolder) { + return m.ErrDashboardTypeMismatch + } + + // check for is someone else has written in between + if dash.Version != existing.Version { + if cmd.Overwrite { + dash.SetVersion(existing.Version) + } else { + return m.ErrDashboardVersionMismatch + } + } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && cmd.Overwrite == false { + return m.UpdatePluginDashboardError{PluginId: existing.PluginId} + } + + return nil +} + +func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashboardBeforeSaveCommand) error { + dash := cmd.Dashboard + var existing m.Dashboard + + exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existing) + if err != nil { + return err + } + + if exists && dash.Id != existing.Id { + if existing.IsFolder && !dash.IsFolder { + return m.ErrDashboardWithSameNameAsFolder + } + + if !existing.IsFolder && dash.IsFolder { + return m.ErrDashboardFolderWithSameNameAsDashboard + } + + if cmd.Overwrite { + dash.SetId(existing.Id) + dash.SetUid(existing.Uid) + dash.SetVersion(existing.Version) + } else { + return m.ErrDashboardWithSameNameInFolderExists + } + } + + return nil +} + +func ValidateDashboardBeforeSave(cmd *m.ValidateDashboardBeforeSaveCommand) (err error) { + return inTransaction(func(sess *DBSession) error { + if err = getExistingDashboardByIdOrUidForUpdate(sess, cmd); err != nil { + return err + } + + if err = getExistingDashboardByTitleAndFolder(sess, cmd); err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go index 40d6cf5bcb2..4c92c097931 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -3,7 +3,6 @@ package sqlstore import ( "testing" - "github.com/go-xorm/xorm" . "github.com/smartystreets/goconvey/convey" m "github.com/grafana/grafana/pkg/models" @@ -11,10 +10,8 @@ import ( ) func TestDashboardFolderDataAccess(t *testing.T) { - var x *xorm.Engine - Convey("Testing DB", t, func() { - x = InitTestDB(t) + InitTestDB(t) Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() { folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") @@ -49,6 +46,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}, } err := SearchDashboards(query) + So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index 54068334b4b..69409c3b873 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -26,8 +26,8 @@ func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error } cmd.Result = cmd.DashboardCmd.Result - if cmd.DashboardProvisioning.Updated.IsZero() { - cmd.DashboardProvisioning.Updated = cmd.Result.Updated + if cmd.DashboardProvisioning.Updated == 0 { + cmd.DashboardProvisioning.Updated = cmd.Result.Updated.Unix() } return saveProvionedData(sess, cmd.DashboardProvisioning, cmd.Result) diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go index 8b2ed7ff061..b752173b67d 100644 --- a/pkg/services/sqlstore/dashboard_provisioning_test.go +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -31,7 +31,7 @@ func TestDashboardProvisioningTest(t *testing.T) { DashboardProvisioning: &models.DashboardProvisioning{ Name: "default", ExternalId: "/var/grafana.json", - Updated: now, + Updated: now.Unix(), }, } @@ -48,7 +48,7 @@ func TestDashboardProvisioningTest(t *testing.T) { So(len(query.Result), ShouldEqual, 1) So(query.Result[0].DashboardId, ShouldEqual, dashId) - So(query.Result[0].Updated.Unix(), ShouldEqual, now.Unix()) + So(query.Result[0].Updated, ShouldEqual, now.Unix()) }) }) }) diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go new file mode 100644 index 00000000000..d005270c33c --- /dev/null +++ b/pkg/services/sqlstore/dashboard_service_integration_test.go @@ -0,0 +1,932 @@ +package sqlstore + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestIntegratedDashboardService(t *testing.T) { + Convey("Dashboard service integration tests", t, func() { + InitTestDB(t) + var testOrgId int64 = 1 + + Convey("Given saved folders and dashboards in organization A", func() { + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + savedFolder := saveTestFolder("Saved folder", testOrgId) + savedDashInFolder := saveTestDashboard("Saved dash in folder", testOrgId, savedFolder.Id) + saveTestDashboard("Other saved dash in folder", testOrgId, savedFolder.Id) + savedDashInGeneralFolder := saveTestDashboard("Saved dashboard in general folder", testOrgId, 0) + otherSavedFolder := saveTestFolder("Other saved folder", testOrgId) + + Convey("Should return dashboard model", func() { + So(savedFolder.Title, ShouldEqual, "Saved folder") + So(savedFolder.Slug, ShouldEqual, "saved-folder") + So(savedFolder.Id, ShouldNotEqual, 0) + So(savedFolder.IsFolder, ShouldBeTrue) + So(savedFolder.FolderId, ShouldEqual, 0) + So(len(savedFolder.Uid), ShouldBeGreaterThan, 0) + + So(savedDashInFolder.Title, ShouldEqual, "Saved dash in folder") + So(savedDashInFolder.Slug, ShouldEqual, "saved-dash-in-folder") + So(savedDashInFolder.Id, ShouldNotEqual, 0) + So(savedDashInFolder.IsFolder, ShouldBeFalse) + So(savedDashInFolder.FolderId, ShouldEqual, savedFolder.Id) + So(len(savedDashInFolder.Uid), ShouldBeGreaterThan, 0) + }) + + // Basic validation tests + + Convey("When saving a dashboard with non-existing id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": float64(123412321), + "title": "Expect error", + }), + } + + err := callSaveWithError(cmd) + + Convey("It should result in not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardNotFound) + }) + }) + + // Given other organization + + Convey("Given organization B", func() { + var otherOrgId int64 = 2 + + Convey("When saving a dashboard with id that are saved in organization A", func() { + cmd := models.SaveDashboardCommand{ + OrgId: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "Expect error", + }), + Overwrite: false, + } + + err := callSaveWithError(cmd) + + Convey("It should result in not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardNotFound) + }) + }) + + permissionScenario("Given user has permission to save", true, func(sc *dashboardPermissionScenarioContext) { + Convey("When saving a dashboard with uid that are saved in organization A", func() { + var otherOrgId int64 = 2 + cmd := models.SaveDashboardCommand{ + OrgId: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Dash with existing uid in other org", + }), + Overwrite: false, + } + + res := callSaveWithResult(cmd) + + Convey("It should create dashboard in other organization", func() { + So(res, ShouldNotBeNil) + + query := models.GetDashboardQuery{OrgId: otherOrgId, Uid: savedDashInFolder.Uid} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldNotEqual, savedDashInFolder.Id) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.OrgId, ShouldEqual, otherOrgId) + So(query.Result.Uid, ShouldEqual, savedDashInFolder.Uid) + }) + }) + }) + }) + + // Given user has no permission to save + + permissionScenario("Given user has no permission to save", false, func(sc *dashboardPermissionScenarioContext) { + + Convey("When trying to create a new dashboard in the General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to create a new dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash", + }), + FolderId: otherSavedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and rsult in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to update a dashboard by existing id in the General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Dash", + }), + FolderId: savedDashInGeneralFolder.FolderId, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInGeneralFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When trying to update a dashboard by existing id in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "Dash", + }), + FolderId: savedDashInFolder.FolderId, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + }) + + // Given user has permission to save + + permissionScenario("Given user has permission to save", true, func(sc *dashboardPermissionScenarioContext) { + + Convey("and overwrite flag is set to false", func() { + shouldOverwrite := false + + Convey("When creating a dashboard in General folder with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.FolderId, ShouldEqual, 0) + }) + }) + + Convey("When creating a dashboard in other folder with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + So(res.Id, ShouldNotEqual, savedDashInGeneralFolder.Id) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + }) + }) + + Convey("When creating a folder with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new folder", func() { + So(res.Id, ShouldNotEqual, savedDashInGeneralFolder.Id) + So(res.IsFolder, ShouldBeTrue) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.IsFolder, ShouldBeTrue) + }) + }) + + Convey("When saving a dashboard without id and uid and unique title in folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash without id and uid", + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + So(res.Id, ShouldBeGreaterThan, 0) + So(len(res.Uid), ShouldBeGreaterThan, 0) + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When saving a dashboard when dashboard id is zero ", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": 0, + "title": "Dash with zero id", + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should create a new dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + }) + }) + + Convey("When saving a dashboard in non-existing folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Expect error", + }), + FolderId: 123412321, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in folder not found error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardFolderNotFound) + }) + }) + + Convey("When updating an existing dashboard by id without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "test dash 23", + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in version mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardVersionMismatch) + }) + }) + + Convey("When updating an existing dashboard by id with current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Updated title", + "version": savedDashInGeneralFolder.Version, + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInGeneralFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInGeneralFolder.Version) + }) + }) + + Convey("When updating an existing dashboard by uid without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "test dash 23", + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in version mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardVersionMismatch) + }) + }) + + Convey("When updating an existing dashboard by uid with current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Updated title", + "version": savedDashInFolder.Version, + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: savedDashInFolder.FolderId, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedDashInGeneralFolder.FolderId, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + + Convey("When creating a folder with same name as existing folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name in folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameInFolderExists) + }) + }) + }) + + Convey("and overwrite flag is set to true", func() { + shouldOverwrite := true + + Convey("When updating an existing dashboard by id without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Updated title", + }), + FolderId: savedFolder.Id, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInGeneralFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, savedFolder.Id) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInGeneralFolder.Version) + }) + }) + + Convey("When updating an existing dashboard by uid without current version", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Updated title", + }), + FolderId: 0, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + So(res, ShouldNotBeNil) + + Convey("It should update dashboard", func() { + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Title, ShouldEqual, "Updated title") + So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When updating uid for existing dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "uid": "new-uid", + "title": savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should update dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInFolder.Id) + So(res.Uid, ShouldEqual, "new-uid") + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: savedDashInFolder.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Uid, ShouldEqual, "new-uid") + So(query.Result.Version, ShouldBeGreaterThan, savedDashInFolder.Version) + }) + }) + + Convey("When updating uid to an existing uid for existing dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "uid": savedDashInGeneralFolder.Uid, + "title": savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in same uid exists error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameUIDExists) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInFolder.Title, + }), + FolderId: savedDashInFolder.FolderId, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should overwrite existing dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInFolder.Id) + So(res.Uid, ShouldEqual, savedDashInFolder.Uid) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When creating a dashboard with same name as dashboard in General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": savedDashInGeneralFolder.Title, + }), + FolderId: savedDashInGeneralFolder.FolderId, + Overwrite: shouldOverwrite, + } + + res := callSaveWithResult(cmd) + + Convey("It should overwrite existing dashboard", func() { + So(res, ShouldNotBeNil) + So(res.Id, ShouldEqual, savedDashInGeneralFolder.Id) + So(res.Uid, ShouldEqual, savedDashInGeneralFolder.Uid) + + query := models.GetDashboardQuery{OrgId: cmd.OrgId, Id: res.Id} + + err := bus.Dispatch(&query) + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, res.Id) + So(query.Result.Uid, ShouldEqual, res.Uid) + }) + }) + + Convey("When trying to update existing folder to a dashboard using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedFolder.Id, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing dashboard to a folder using id", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing folder to a dashboard using uid", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedFolder.Uid, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing dashboard to a folder using uid", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in type mismatch error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardTypeMismatch) + }) + }) + + Convey("When trying to update existing folder to a dashboard using title", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedFolder.Title, + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in dashboard with same name as folder error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardWithSameNameAsFolder) + }) + }) + + Convey("When trying to update existing dashboard to a folder using title", func() { + cmd := models.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedDashInGeneralFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + err := callSaveWithError(cmd) + + Convey("It should result in folder with same name as dashboard error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardFolderWithSameNameAsDashboard) + }) + }) + }) + }) + }) + }) +} + +type scenarioContext struct { + dashboardGuardianMock *guardian.FakeDashboardGuardian +} + +type scenarioFunc func(c *scenarioContext) + +func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + guardian.MockDashboardGuardian(mock) + + sc := &scenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +type dashboardPermissionScenarioContext struct { + dashboardGuardianMock *guardian.FakeDashboardGuardian +} + +type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext) + +func dashboardPermissionScenario(desc string, mock *guardian.FakeDashboardGuardian, fn dashboardPermissionScenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + guardian.MockDashboardGuardian(mock) + + sc := &dashboardPermissionScenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) { + mock := &guardian.FakeDashboardGuardian{ + CanSaveValue: canSave, + } + dashboardPermissionScenario(desc, mock, fn) +} + +func callSaveWithResult(cmd models.SaveDashboardCommand) *models.Dashboard { + dto := toSaveDashboardDto(cmd) + res, _ := dashboards.NewService().SaveDashboard(&dto) + return res +} + +func callSaveWithError(cmd models.SaveDashboardCommand) error { + dto := toSaveDashboardDto(cmd) + _, err := dashboards.NewService().SaveDashboard(&dto) + return err +} + +func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { + Convey(desc, func() { + origNewDashboardGuardian := guardian.New + guardian.MockDashboardGuardian(mock) + + sc := &scenarioContext{ + dashboardGuardianMock: mock, + } + + defer func() { + guardian.New = origNewDashboardGuardian + }() + + fn(sc) + }) +} + +func saveTestDashboard(title string, orgId int64, folderId int64) *models.Dashboard { + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + } + + dto := dashboards.SaveDashboardDTO{ + OrgId: orgId, + Dashboard: cmd.GetDashboardModel(), + User: &models.SignedInUser{ + UserId: 1, + OrgRole: models.ROLE_ADMIN, + }, + } + + res, err := dashboards.NewService().SaveDashboard(&dto) + So(err, ShouldBeNil) + + return res +} + +func saveTestFolder(title string, orgId int64) *models.Dashboard { + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: 0, + IsFolder: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + } + + dto := dashboards.SaveDashboardDTO{ + OrgId: orgId, + Dashboard: cmd.GetDashboardModel(), + User: &models.SignedInUser{ + UserId: 1, + OrgRole: models.ROLE_ADMIN, + }, + } + + res, err := dashboards.NewService().SaveDashboard(&dto) + So(err, ShouldBeNil) + + return res +} + +func toSaveDashboardDto(cmd models.SaveDashboardCommand) dashboards.SaveDashboardDTO { + dash := (&cmd).GetDashboardModel() + + return dashboards.SaveDashboardDTO{ + Dashboard: dash, + Message: cmd.Message, + OrgId: cmd.OrgId, + User: &models.SignedInUser{UserId: cmd.UserId}, + Overwrite: cmd.Overwrite, + } +} diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 0ef7f99da67..9e82bbb2c83 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -16,20 +16,23 @@ func init() { bus.AddHandler("sql", DeleteExpiredSnapshots) } +// DeleteExpiredSnapshots removes snapshots with old expiry dates. +// SnapShotRemoveExpired is deprecated and should be removed in the future. +// Snapshot expiry is decided by the user when they share the snapshot. func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error { return inTransaction(func(sess *DBSession) error { - var expiredCount int64 = 0 - - if setting.SnapShotRemoveExpired { - deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?" - expiredResponse, err := x.Exec(deleteExpiredSql, time.Now) - if err != nil { - return err - } - expiredCount, _ = expiredResponse.RowsAffected() + if !setting.SnapShotRemoveExpired { + sqlog.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") + return nil } - sqlog.Debug("Deleted old/expired snaphots", "expired", expiredCount) + deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?" + expiredResponse, err := sess.Exec(deleteExpiredSql, time.Now()) + if err != nil { + return err + } + cmd.DeletedRows, _ = expiredResponse.RowsAffected() + return nil }) } @@ -72,7 +75,7 @@ func DeleteDashboardSnapshot(cmd *m.DeleteDashboardSnapshotCommand) error { } func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { - snapshot := m.DashboardSnapshot{Key: query.Key} + snapshot := m.DashboardSnapshot{Key: query.Key, DeleteKey: query.DeleteKey} has, err := x.Get(&snapshot) if err != nil { @@ -85,6 +88,8 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { return nil } +// SearchDashboardSnapshots returns a list of all snapshots for admins +// for other roles, it returns snapshots created by the user func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { var snapshots = make(m.DashboardSnapshotsList, 0) @@ -95,7 +100,16 @@ func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { sess.Where("name LIKE ?", query.Name) } - sess.Where("org_id = ?", query.OrgId) + // admins can see all snapshots, everyone else can only see their own snapshots + if query.SignedInUser.OrgRole == m.ROLE_ADMIN { + sess.Where("org_id = ?", query.OrgId) + } else if !query.SignedInUser.IsAnonymous { + sess.Where("org_id = ? AND user_id = ?", query.OrgId, query.SignedInUser.UserId) + } else { + query.Result = snapshots + return nil + } + err := sess.Find(&snapshots) query.Result = snapshots return err diff --git a/pkg/services/sqlstore/dashboard_snapshot_test.go b/pkg/services/sqlstore/dashboard_snapshot_test.go index 50375088b4b..2081cbf6194 100644 --- a/pkg/services/sqlstore/dashboard_snapshot_test.go +++ b/pkg/services/sqlstore/dashboard_snapshot_test.go @@ -2,11 +2,14 @@ package sqlstore import ( "testing" + "time" + "github.com/go-xorm/xorm" . "github.com/smartystreets/goconvey/convey" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" ) func TestDashboardSnapshotDBAccess(t *testing.T) { @@ -14,17 +17,19 @@ func TestDashboardSnapshotDBAccess(t *testing.T) { Convey("Testing DashboardSnapshot data access", t, func() { InitTestDB(t) - Convey("Given saved snaphot", func() { + Convey("Given saved snapshot", func() { cmd := m.CreateDashboardSnapshotCommand{ Key: "hej", Dashboard: simplejson.NewFromAny(map[string]interface{}{ "hello": "mupp", }), + UserId: 1000, + OrgId: 1, } err := CreateDashboardSnapshot(&cmd) So(err, ShouldBeNil) - Convey("Should be able to get snaphot by key", func() { + Convey("Should be able to get snapshot by key", func() { query := m.GetDashboardSnapshotQuery{Key: "hej"} err = GetDashboardSnapshot(&query) So(err, ShouldBeNil) @@ -33,6 +38,135 @@ func TestDashboardSnapshotDBAccess(t *testing.T) { So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp") }) + Convey("And the user has the admin role", func() { + Convey("Should return all the snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 1) + }) + }) + + Convey("And the user has the editor role and has created a snapshot", func() { + Convey("Should return all the snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 1000}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 1) + }) + }) + + Convey("And the user has the editor role and has not created any snapshot", func() { + Convey("Should not return any snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 2}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 0) + }) + }) + + Convey("And the user is anonymous", func() { + cmd := m.CreateDashboardSnapshotCommand{ + Key: "strangesnapshotwithuserid0", + DeleteKey: "adeletekey", + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "hello": "mupp", + }), + UserId: 0, + OrgId: 1, + } + err := CreateDashboardSnapshot(&cmd) + So(err, ShouldBeNil) + + Convey("Should not return any snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, IsAnonymous: true, UserId: 0}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 0) + }) + }) }) }) } + +func TestDeleteExpiredSnapshots(t *testing.T) { + Convey("Testing dashboard snapshots clean up", t, func() { + x := InitTestDB(t) + + setting.SnapShotRemoveExpired = true + + notExpiredsnapshot := createTestSnapshot(x, "key1", 1000) + createTestSnapshot(x, "key2", -1000) + createTestSnapshot(x, "key3", -1000) + + Convey("Clean up old dashboard snapshots", func() { + err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{}) + So(err, ShouldBeNil) + + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + err = SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key) + }) + + Convey("Don't delete anything if there are no expired snapshots", func() { + err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{}) + So(err, ShouldBeNil) + + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + SearchDashboardSnapshots(&query) + + So(len(query.Result), ShouldEqual, 1) + }) + }) +} + +func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardSnapshot { + cmd := m.CreateDashboardSnapshotCommand{ + Key: key, + DeleteKey: "delete" + key, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "hello": "mupp", + }), + UserId: 1000, + OrgId: 1, + Expires: expires, + } + err := CreateDashboardSnapshot(&cmd) + So(err, ShouldBeNil) + + // Set expiry date manually - to be able to create expired snapshots + expireDate := time.Now().Add(time.Second * time.Duration(expires)) + _, err = x.Exec("update dashboard_snapshot set expires = ? where "+dialect.Quote("key")+" = ?", expireDate, key) + So(err, ShouldBeNil) + + return cmd.Result +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 7de4c5f5701..9124a686236 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -3,8 +3,8 @@ package sqlstore import ( "fmt" "testing" + "time" - "github.com/go-xorm/xorm" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" @@ -14,10 +14,8 @@ import ( ) func TestDashboardDataAccess(t *testing.T) { - var x *xorm.Engine - Convey("Testing DB", t, func() { - x = InitTestDB(t) + InitTestDB(t) Convey("Given saved dashboard", func() { savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") @@ -100,324 +98,6 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) }) - Convey("Should return not found error if no dashboard is found for update", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Overwrite: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": float64(123412321), - "title": "Expect error", - "tags": []interface{}{}, - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardNotFound) - }) - - Convey("Should not be able to overwrite dashboard in another org", func() { - query := m.GetDashboardQuery{Slug: "test-dash-23", OrgId: 1} - GetDashboard(&query) - - cmd := m.SaveDashboardCommand{ - OrgId: 2, - Overwrite: true, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": float64(query.Result.Id), - "title": "Expect error", - "tags": []interface{}{}, - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardNotFound) - }) - - Convey("Should be able to save dashboards with same name in different folders", func() { - firstSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "randomHash", - }), - FolderId: 3, - } - - err := SaveDashboard(&firstSaveCmd) - So(err, ShouldBeNil) - - secondSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "moreRandomHash", - }), - FolderId: 1, - } - - err = SaveDashboard(&secondSaveCmd) - So(err, ShouldBeNil) - So(firstSaveCmd.Result.Id, ShouldNotEqual, secondSaveCmd.Result.Id) - }) - - Convey("Should be able to overwrite dashboard in same folder using title", func() { - insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") - folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") - dashInFolder := insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") - - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": "Dash", - }), - FolderId: folder.Id, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.Id, ShouldEqual, dashInFolder.Id) - So(cmd.Result.Uid, ShouldEqual, dashInFolder.Uid) - }) - - Convey("Should be able to overwrite dashboard in General folder using title", func() { - dashInGeneral := insertTestDashboard("Dash", 1, 0, false, "prod", "webapp") - folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp") - insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp") - - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": "Dash", - }), - FolderId: 0, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.Id, ShouldEqual, dashInGeneral.Id) - So(cmd.Result.Uid, ShouldEqual, dashInGeneral.Uid) - }) - - Convey("Should not be able to overwrite folder with dashboard in general folder using title", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": savedFolder.Title, - }), - FolderId: 0, - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) - }) - - Convey("Should not be able to overwrite folder with dashboard in folder using title", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": savedFolder.Title, - }), - FolderId: savedFolder.Id, - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder) - }) - - Convey("Should not be able to overwrite folder with dashboard using id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": savedFolder.Id, - "title": "new title", - }), - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite dashboard with folder using id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": savedDash.Id, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite folder with dashboard using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedFolder.Uid, - "title": "new title", - }), - IsFolder: false, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to overwrite dashboard with folder using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldEqual, m.ErrDashboardTypeMismatch) - }) - - Convey("Should not be able to save dashboard with same name in the same folder without overwrite", func() { - firstSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "randomHash", - }), - FolderId: 3, - } - - err := SaveDashboard(&firstSaveCmd) - So(err, ShouldBeNil) - - secondSaveCmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test dash folder and title", - "tags": []interface{}{}, - "uid": "moreRandomHash", - }), - FolderId: 3, - } - - err = SaveDashboard(&secondSaveCmd) - So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists) - }) - - Convey("Should be able to save and update dashboard using same uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "uid": "dsfalkjngailuedt", - "title": "test dash 23", - }), - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - err = SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - - Convey("Should be able to update dashboard using uid", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "new title", - }), - FolderId: 0, - Overwrite: true, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - - Convey("Should be able to get updated dashboard by uid", func() { - query := m.GetDashboardQuery{ - Uid: savedDash.Uid, - OrgId: 1, - } - - err := GetDashboard(&query) - So(err, ShouldBeNil) - - So(query.Result.Id, ShouldEqual, savedDash.Id) - So(query.Result.Title, ShouldEqual, "new title") - So(query.Result.FolderId, ShouldEqual, 0) - }) - }) - - Convey("Should be able to update dashboard with the same title and folder id", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": "randomHash", - "title": "folderId", - "style": "light", - "tags": []interface{}{}, - }), - FolderId: 2, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - So(cmd.Result.FolderId, ShouldEqual, 2) - - cmd = m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": cmd.Result.Id, - "uid": "randomHash", - "title": "folderId", - "style": "dark", - "version": cmd.Result.Version, - "tags": []interface{}{}, - }), - FolderId: 2, - } - - err = SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - - Convey("Should be able to update using uid without id and overwrite", func() { - cmd := m.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "uid": savedDash.Uid, - "title": "folderId", - "version": savedDash.Version, - "tags": []interface{}{}, - }), - FolderId: savedDash.FolderId, - } - - err := SaveDashboard(&cmd) - So(err, ShouldBeNil) - }) - Convey("Should retry generation of uid once if it fails.", func() { timesCalled := 0 generateNewUid = func() string { @@ -442,6 +122,24 @@ func TestDashboardDataAccess(t *testing.T) { generateNewUid = util.GenerateShortUid }) + Convey("Should be able to create dashboard", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "folderId", + "tags": []interface{}{}, + }), + UserId: 100, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.CreatedBy, ShouldEqual, 100) + So(cmd.Result.Created.IsZero(), ShouldBeFalse) + So(cmd.Result.UpdatedBy, ShouldEqual, 100) + So(cmd.Result.Updated.IsZero(), ShouldBeFalse) + }) + Convey("Should be able to update dashboard by id and remove folderId", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, @@ -452,6 +150,7 @@ func TestDashboardDataAccess(t *testing.T) { }), Overwrite: true, FolderId: 2, + UserId: 100, } err := SaveDashboard(&cmd) @@ -467,6 +166,7 @@ func TestDashboardDataAccess(t *testing.T) { }), FolderId: 0, Overwrite: true, + UserId: 100, } err = SaveDashboard(&cmd) @@ -480,6 +180,10 @@ func TestDashboardDataAccess(t *testing.T) { err = GetDashboard(&query) 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.UpdatedBy, ShouldEqual, 100) + So(query.Result.Updated.IsZero(), ShouldBeFalse) }) Convey("Should be able to delete a dashboard folder and its children", func() { @@ -499,6 +203,36 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 0) }) + Convey("Should return error if no dashboard is found for update when dashboard id is greater than zero", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Overwrite: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": float64(123412321), + "title": "Expect error", + "tags": []interface{}{}, + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardNotFound) + }) + + Convey("Should not return error if no dashboard is found for update when dashboard id is zero", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Overwrite: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": 0, + "title": "New dash", + "tags": []interface{}{}, + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + }) + Convey("Should be able to get dashboard tags", func() { query := m.GetDashboardTagsQuery{OrgId: 1} @@ -627,6 +361,9 @@ func insertTestDashboard(title string, orgId int64, folderId int64, isFolder boo err := SaveDashboard(&cmd) So(err, ShouldBeNil) + cmd.Result.Data.Set("id", cmd.Result.Id) + cmd.Result.Data.Set("uid", cmd.Result.Uid) + return cmd.Result } diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go index 49c35397094..1f2850b2021 100644 --- a/pkg/services/sqlstore/dashboard_version.go +++ b/pkg/services/sqlstore/dashboard_version.go @@ -67,72 +67,48 @@ func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error { return nil } +const MAX_VERSIONS_TO_DELETE = 100 + func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error { return inTransaction(func(sess *DBSession) error { - expiredCount := int64(0) - versions := []DashboardVersionExp{} versionsToKeep := setting.DashboardVersionsToKeep - if versionsToKeep < 1 { versionsToKeep = 1 } - err := sess.Table("dashboard_version"). - Select("dashboard_version.id, dashboard_version.version, dashboard_version.dashboard_id"). - Where(`dashboard_id IN ( - SELECT dashboard_id FROM dashboard_version - GROUP BY dashboard_id HAVING COUNT(dashboard_version.id) > ? - )`, versionsToKeep). - Desc("dashboard_version.dashboard_id", "dashboard_version.version"). - Find(&versions) + // Idea of this query is finding version IDs to delete based on formula: + // min_version_to_keep = min_version + (versions_count - versions_to_keep) + // where version stats is processed for each dashboard. This guarantees that we keep at least versions_to_keep + // versions, but in some cases (when versions are sparse) this number may be more. + versionIdsToDeleteQuery := `SELECT id + FROM dashboard_version, ( + SELECT dashboard_id, count(version) as count, min(version) as min + FROM dashboard_version + GROUP BY dashboard_id + ) AS vtd + WHERE dashboard_version.dashboard_id=vtd.dashboard_id + AND version < vtd.min + vtd.count - ?` + var versionIdsToDelete []interface{} + err := sess.SQL(versionIdsToDeleteQuery, versionsToKeep).Find(&versionIdsToDelete) if err != nil { return err } - // Keep last versionsToKeep versions and delete other - versionIdsToDelete := getVersionIDsToDelete(versions, versionsToKeep) + // Don't delete more than MAX_VERSIONS_TO_DELETE version per time + if len(versionIdsToDelete) > MAX_VERSIONS_TO_DELETE { + versionIdsToDelete = versionIdsToDelete[:MAX_VERSIONS_TO_DELETE] + } + if len(versionIdsToDelete) > 0 { deleteExpiredSql := `DELETE FROM dashboard_version WHERE id IN (?` + strings.Repeat(",?", len(versionIdsToDelete)-1) + `)` expiredResponse, err := sess.Exec(deleteExpiredSql, versionIdsToDelete...) if err != nil { return err } - expiredCount, _ = expiredResponse.RowsAffected() - sqlog.Debug("Deleted old/expired dashboard versions", "expired", expiredCount) + cmd.DeletedRows, _ = expiredResponse.RowsAffected() } return nil }) } - -// Short version of DashboardVersion for getting expired versions -type DashboardVersionExp struct { - Id int64 `json:"id"` - DashboardId int64 `json:"dashboardId"` - Version int `json:"version"` -} - -func getVersionIDsToDelete(versions []DashboardVersionExp, versionsToKeep int) []interface{} { - versionIds := make([]interface{}, 0) - - if len(versions) == 0 { - return versionIds - } - - currentDashboard := versions[0].DashboardId - count := 0 - for _, v := range versions { - if v.DashboardId == currentDashboard { - count++ - } else { - count = 1 - currentDashboard = v.DashboardId - } - if count > versionsToKeep { - versionIds = append(versionIds, v.Id) - } - } - - return versionIds -} diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index e20ac897b3d..a6403755d05 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -12,7 +12,7 @@ import ( ) func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) { - data["uid"] = dashboard.Uid + data["id"] = dashboard.Id saveCmd := m.SaveDashboardCommand{ OrgId: dashboard.OrgId, @@ -136,10 +136,30 @@ func TestDeleteExpiredVersions(t *testing.T) { err := DeleteExpiredVersions(&m.DeleteExpiredVersionsCommand{}) So(err, ShouldBeNil) - query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1} + query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1, Limit: versionsToWrite} GetDashboardVersions(&query) So(len(query.Result), ShouldEqual, versionsToWrite) }) + + Convey("Don't delete more than MAX_VERSIONS_TO_DELETE per iteration", func() { + versionsToWriteBigNumber := MAX_VERSIONS_TO_DELETE + versionsToWrite + for i := 0; i < versionsToWriteBigNumber-versionsToWrite; i++ { + updateTestDashboard(savedDash, map[string]interface{}{ + "tags": "different-tag", + }) + } + + err := DeleteExpiredVersions(&m.DeleteExpiredVersionsCommand{}) + So(err, ShouldBeNil) + + query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1, Limit: versionsToWriteBigNumber} + GetDashboardVersions(&query) + + // Ensure we have at least versionsToKeep versions + So(len(query.Result), ShouldBeGreaterThanOrEqualTo, versionsToKeep) + // Ensure we haven't deleted more than MAX_VERSIONS_TO_DELETE rows + So(versionsToWriteBigNumber-len(query.Result), ShouldBeLessThanOrEqualTo, MAX_VERSIONS_TO_DELETE) + }) }) } diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index e9b400a1772..00d520bcfc6 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -27,6 +27,9 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error { datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id} has, err := x.Get(&datasource) + if err != nil { + return err + } if !has { return m.ErrDataSourceNotFound diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index 28f5b8eba9d..90300e20029 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -1,61 +1,13 @@ package sqlstore import ( - "os" - "strings" "testing" - "github.com/go-xorm/xorm" - . "github.com/smartystreets/goconvey/convey" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" ) -var ( - dbSqlite = "sqlite" - dbMySql = "mysql" - dbPostgres = "postgres" -) - -func InitTestDB(t *testing.T) *xorm.Engine { - selectedDb := dbSqlite - //selectedDb := dbMySql - //selectedDb := dbPostgres - - var x *xorm.Engine - var err error - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - selectedDb = db - } - - switch strings.ToLower(selectedDb) { - case dbMySql: - x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) - case dbPostgres: - x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) - default: - x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) - } - - // x.ShowSQL() - - if err != nil { - t.Fatalf("Failed to init in memory sqllite3 db %v", err) - } - - sqlutil.CleanDB(x) - - if err := SetEngine(x); err != nil { - t.Fatal(err) - } - - return x -} - type Test struct { Id int64 Name string diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go index 805d726df48..78da198e8e7 100644 --- a/pkg/services/sqlstore/login_attempt.go +++ b/pkg/services/sqlstore/login_attempt.go @@ -21,7 +21,7 @@ func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { loginAttempt := m.LoginAttempt{ Username: cmd.Username, IpAddress: cmd.IpAddress, - Created: getTimeNow(), + Created: getTimeNow().Unix(), } if _, err := sess.Insert(&loginAttempt); err != nil { @@ -37,8 +37,8 @@ func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { return inTransaction(func(sess *DBSession) error { var maxId int64 - sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") - result, err := sess.Query(sql, cmd.OlderThan) + sql := "SELECT max(id) as id FROM login_attempt WHERE created < ?" + result, err := sess.Query(sql, cmd.OlderThan.Unix()) if err != nil { return err @@ -66,7 +66,7 @@ func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { loginAttempt := new(m.LoginAttempt) total, err := x. Where("username = ?", query.Username). - And("created >="+dialect.DateTimeFunc("?"), query.Since). + And("created >= ?", query.Since.Unix()). Count(loginAttempt) if err != nil { diff --git a/pkg/services/sqlstore/migrations/common.go b/pkg/services/sqlstore/migrations/common.go index cf0b39f1a35..cc31b1d4580 100644 --- a/pkg/services/sqlstore/migrations/common.go +++ b/pkg/services/sqlstore/migrations/common.go @@ -24,3 +24,24 @@ func addTableRenameMigration(mg *Migrator, oldName string, newName string, versi migrationId := fmt.Sprintf("Rename table %s to %s - %s", oldName, newName, versionSuffix) mg.AddMigration(migrationId, NewRenameTableMigration(oldName, newName)) } + +func addTableReplaceMigrations(mg *Migrator, from Table, to Table, migrationVersion int64, tableDataMigration map[string]string) { + fromV := version(migrationVersion - 1) + toV := version(migrationVersion) + tmpTableName := to.Name + "_tmp_qwerty" + + createTable := fmt.Sprintf("create %v %v", to.Name, toV) + copyTableData := fmt.Sprintf("copy %v %v to %v", to.Name, fromV, toV) + dropTable := fmt.Sprintf("drop %v", tmpTableName) + + addDropAllIndicesMigrations(mg, fromV, from) + addTableRenameMigration(mg, from.Name, tmpTableName, fromV) + mg.AddMigration(createTable, NewAddTableMigration(to)) + addTableIndicesMigrations(mg, toV, to) + mg.AddMigration(copyTableData, NewCopyTableDataMigration(to.Name, tmpTableName, tableDataMigration)) + mg.AddMigration(dropTable, NewDropTableMigration(tmpTableName)) +} + +func version(v int64) string { + return fmt.Sprintf("v%v", v) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 1c40e241e15..296950ee497 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -1,6 +1,8 @@ package migrations -import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) func addDashboardMigration(mg *Migrator) { var dashboardV1 = Table{ @@ -181,15 +183,34 @@ func addDashboardMigration(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "dashboard_id", Type: DB_BigInt, Nullable: true}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 150, Nullable: false}, {Name: "external_id", Type: DB_Text, Nullable: false}, {Name: "updated", Type: DB_DateTime, Nullable: false}, }, + Indices: []*Index{}, + } + + mg.AddMigration("create dashboard_provisioning", NewAddTableMigration(dashboardExtrasTable)) + + dashboardExtrasTableV2 := Table{ + Name: "dashboard_provisioning", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "dashboard_id", Type: DB_BigInt, Nullable: true}, + {Name: "name", Type: DB_NVarchar, Length: 150, Nullable: false}, + {Name: "external_id", Type: DB_Text, Nullable: false}, + {Name: "updated", Type: DB_Int, Default: "0", Nullable: false}, + }, Indices: []*Index{ {Cols: []string{"dashboard_id"}}, {Cols: []string{"dashboard_id", "name"}, Type: IndexType}, }, } - mg.AddMigration("create dashboard_provisioning", NewAddTableMigration(dashboardExtrasTable)) + addTableReplaceMigrations(mg, dashboardExtrasTable, dashboardExtrasTableV2, 2, map[string]string{ + "id": "id", + "dashboard_id": "dashboard_id", + "name": "name", + "external_id": "external_id", + }) } diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go index e576ccd1a50..df14eb4effa 100644 --- a/pkg/services/sqlstore/migrations/login_attempt_mig.go +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -20,4 +20,23 @@ func addLoginAttemptMigrations(mg *Migrator) { mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) // add indices mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) + + loginAttemptV2 := Table{ + Name: "login_attempt", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, + {Name: "created", Type: DB_Int, Default: "0", Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"username"}}, + }, + } + + addTableReplaceMigrations(mg, loginAttemptV1, loginAttemptV2, 2, map[string]string{ + "id": "id", + "username": "username", + "ip_address": "ip_address", + }) } diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 5bddf6ff605..51aea0bbdef 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -14,13 +14,15 @@ import ( var indexTypes = []string{"Unknown", "INDEX", "UNIQUE INDEX"} func TestMigrations(t *testing.T) { - //log.NewLogger(0, "console", `{"level": 0}`) - testDBs := []sqlutil.TestDB{ sqlutil.TestDB_Sqlite3, } for _, testDB := range testDBs { + sql := `select count(*) as count from migration_log` + r := struct { + Count int64 + }{} Convey("Initial "+testDB.DriverName+" migration", t, func() { x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) @@ -28,30 +30,31 @@ func TestMigrations(t *testing.T) { sqlutil.CleanDB(x) + has, err := x.SQL(sql).Get(&r) + So(err, ShouldNotBeNil) + mg := NewMigrator(x) AddMigrations(mg) err = mg.Start() So(err, ShouldBeNil) - // tables, err := x.DBMetas() - // So(err, ShouldBeNil) - // - // fmt.Printf("\nDB Schema after migration: table count: %v\n", len(tables)) - // - // for _, table := range tables { - // fmt.Printf("\nTable: %v \n", table.Name) - // for _, column := range table.Columns() { - // fmt.Printf("\t %v \n", column.String(x.Dialect())) - // } - // - // if len(table.Indexes) > 0 { - // fmt.Printf("\n\tIndexes:\n") - // for _, index := range table.Indexes { - // fmt.Printf("\t %v (%v) %v \n", index.Name, strings.Join(index.Cols, ","), indexTypes[index.Type]) - // } - // } - // } + has, err = x.SQL(sql).Get(&r) + So(err, ShouldBeNil) + So(has, ShouldBeTrue) + expectedMigrations := mg.MigrationsCount() - 2 //we currently skip to migrations. We should rewrite skipped migrations to write in the log as well. until then we have to keep this + So(r.Count, ShouldEqual, expectedMigrations) + + mg = NewMigrator(x) + AddMigrations(mg) + + err = mg.Start() + So(err, ShouldBeNil) + + has, err = x.SQL(sql).Get(&r) + So(err, ShouldBeNil) + So(has, ShouldBeTrue) + So(r.Count, ShouldEqual, expectedMigrations) }) } } diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index 64831ee46b4..0fde3f27c01 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -35,6 +35,10 @@ func NewMigrator(engine *xorm.Engine) *Migrator { return mg } +func (mg *Migrator) MigrationsCount() int { + return len(mg.migrations) +} + func (mg *Migrator) AddMigration(id string, m Migration) { m.SetId(id) mg.migrations = append(mg.migrations, m) @@ -121,7 +125,7 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error { condition := m.GetCondition() if condition != nil { sql, args := condition.Sql(mg.dialect) - results, err := sess.Query(sql, args...) + results, err := sess.SQL(sql).Query(args...) if err != nil || len(results) == 0 { mg.Logger.Info("Skipping migration condition not fulfilled", "id", m.Id()) return sess.Rollback() diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index c57d15a48d5..63b20aa6e86 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -2,6 +2,7 @@ package sqlstore import ( "testing" + "time" . "github.com/smartystreets/goconvey/convey" @@ -241,6 +242,8 @@ func TestAccountDataAccess(t *testing.T) { func testHelperUpdateDashboardAcl(dashboardId int64, items ...m.DashboardAcl) error { cmd := m.UpdateDashboardAclCommand{DashboardId: dashboardId} for _, item := range items { + item.Created = time.Now() + item.Updated = time.Now() cmd.Items = append(cmd.Items, &item) } return UpdateDashboardAcl(&cmd) diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 0a857efce40..3db3fc2657e 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -2,6 +2,7 @@ package sqlstore import ( "fmt" + "time" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" @@ -98,8 +99,9 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, + Target: cmd.Target, + OrgId: cmd.OrgId, + Updated: time.Now(), } has, err := sess.Get("a) if err != nil { @@ -107,6 +109,7 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { } quota.Limit = cmd.Limit if has == false { + quota.Created = time.Now() //No quota in the DB for this target, so create a new one. if _, err := sess.Insert("a); err != nil { return err @@ -198,8 +201,9 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, + Target: cmd.Target, + UserId: cmd.UserId, + Updated: time.Now(), } has, err := sess.Get("a) if err != nil { @@ -207,6 +211,7 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { } quota.Limit = cmd.Limit if has == false { + quota.Created = time.Now() //No quota in the DB for this target, so create a new one. if _, err := sess.Insert("a); err != nil { return err diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go index 5ef618e166d..ed6565b1c3f 100644 --- a/pkg/services/sqlstore/quota_test.go +++ b/pkg/services/sqlstore/quota_test.go @@ -104,12 +104,12 @@ func TestQuotaCommandsAndQueries(t *testing.T) { }) }) Convey("Given saved user quota for org", func() { - userQoutaCmd := m.UpdateUserQuotaCmd{ + userQuotaCmd := m.UpdateUserQuotaCmd{ UserId: userId, Target: "org_user", Limit: 10, } - err := UpdateUserQuota(&userQoutaCmd) + err := UpdateUserQuota(&userQuotaCmd) So(err, ShouldBeNil) Convey("Should be able to get saved quota by user id and target", func() { diff --git a/pkg/services/sqlstore/sqlbuilder.go b/pkg/services/sqlstore/sqlbuilder.go index b38bd693e66..b42c7926203 100644 --- a/pkg/services/sqlstore/sqlbuilder.go +++ b/pkg/services/sqlstore/sqlbuilder.go @@ -12,6 +12,22 @@ type SqlBuilder struct { params []interface{} } +func (sb *SqlBuilder) Write(sql string, params ...interface{}) { + sb.sql.WriteString(sql) + + if len(params) > 0 { + sb.params = append(sb.params, params...) + } +} + +func (sb *SqlBuilder) GetSqlString() string { + return sb.sql.String() +} + +func (sb *SqlBuilder) AddParams(params ...interface{}) { + sb.params = append(sb.params, params...) +} + func (sb *SqlBuilder) writeDashboardPermissionFilter(user *m.SignedInUser, permission m.PermissionType) { if user.OrgRole == m.ROLE_ADMIN { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 2655bf9c22e..6aace350193 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -7,14 +7,16 @@ import ( "path" "path/filepath" "strings" + "testing" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/setting" "github.com/go-sql-driver/mysql" @@ -22,6 +24,8 @@ import ( "github.com/go-xorm/xorm" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" + + _ "github.com/grafana/grafana/pkg/tsdb/mssql" ) type DatabaseConfig struct { @@ -32,6 +36,7 @@ type DatabaseConfig struct { ServerCertName string MaxOpenConn int MaxIdleConn int + ConnMaxLifetime int } var ( @@ -101,7 +106,6 @@ func SetEngine(engine *xorm.Engine) (err error) { // Init repo instances annotations.SetRepository(&SqlAnnotationRepo{}) - dashboards.SetRepository(&dashboards.DashboardRepository{}) return nil } @@ -157,18 +161,20 @@ func getEngine() (*xorm.Engine, error) { engine, err := xorm.NewEngine(DbCfg.Type, cnnstr) if err != nil { return nil, err - } else { - engine.SetMaxOpenConns(DbCfg.MaxOpenConn) - engine.SetMaxIdleConns(DbCfg.MaxIdleConn) - debugSql := setting.Cfg.Section("database").Key("log_queries").MustBool(false) - if !debugSql { - engine.SetLogger(&xorm.DiscardLogger{}) - } else { - engine.SetLogger(NewXormLogger(log.LvlInfo, log.New("sqlstore.xorm"))) - engine.ShowSQL(true) - engine.ShowExecTime(true) - } } + + engine.SetMaxOpenConns(DbCfg.MaxOpenConn) + engine.SetMaxIdleConns(DbCfg.MaxIdleConn) + engine.SetConnMaxLifetime(time.Second * time.Duration(DbCfg.ConnMaxLifetime)) + debugSql := setting.Cfg.Section("database").Key("log_queries").MustBool(false) + if !debugSql { + engine.SetLogger(&xorm.DiscardLogger{}) + } else { + engine.SetLogger(NewXormLogger(log.LvlInfo, log.New("sqlstore.xorm"))) + engine.ShowSQL(true) + engine.ShowExecTime(true) + } + return engine, nil } @@ -202,6 +208,7 @@ func LoadConfig() { } DbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) DbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(0) + DbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) if DbCfg.Type == "sqlite3" { UseSQLite3 = true @@ -216,3 +223,49 @@ func LoadConfig() { DbCfg.ServerCertName = sec.Key("server_cert_name").String() DbCfg.Path = sec.Key("path").MustString("data/grafana.db") } + +var ( + dbSqlite = "sqlite" + dbMySql = "mysql" + dbPostgres = "postgres" +) + +func InitTestDB(t *testing.T) *xorm.Engine { + selectedDb := dbSqlite + // selectedDb := dbMySql + // selectedDb := dbPostgres + + var x *xorm.Engine + var err error + + // environment variable present for test db? + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + selectedDb = db + } + + switch strings.ToLower(selectedDb) { + case dbMySql: + x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr) + case dbPostgres: + x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + default: + x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr) + } + + x.DatabaseTZ = time.UTC + x.TZLocation = time.UTC + + // x.ShowSQL() + + if err != nil { + t.Fatalf("Failed to init in memory sqllite3 db %v", err) + } + + sqlutil.CleanDB(x) + + if err := SetEngine(x); err != nil { + t.Fatal(err) + } + + return x +} diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 26a58811e0f..46306ac8e0d 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -14,6 +14,7 @@ type TestDB struct { var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:"} var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} +var TestDB_Mssql = TestDB{DriverName: "mssql", ConnStr: "server=localhost;port=1433;database=grafanatest;user id=grafana;password=Password!"} func CleanDB(x *xorm.Engine) { if x.DriverName() == "postgres" { diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index ecb34ad927b..d238301c7ce 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -78,11 +78,12 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { }) } +// DeleteTeam will delete a team, its member and any permissions connected to the team func DeleteTeam(cmd *m.DeleteTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, cmd.Id); err != nil { + if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { return err - } else if len(res) != 1 { + } else if !teamExists { return m.ErrTeamNotFound } @@ -102,6 +103,16 @@ func DeleteTeam(cmd *m.DeleteTeamCommand) error { }) } +func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) { + if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil { + return false, err + } else if len(res) != 1 { + return false, nil + } + + return true, nil +} + func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession) (bool, error) { var team m.Team exists, err := sess.Where("org_id=? and name=?", orgId, name).Get(&team) @@ -190,6 +201,7 @@ func GetTeamById(query *m.GetTeamByIdQuery) error { return nil } +// GetTeamsByUser is used by the Guardian when checking a users' permissions func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { query.Result = make([]*m.Team, 0) @@ -205,6 +217,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { return nil } +// AddTeamMember adds a user to a team func AddTeamMember(cmd *m.AddTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { if res, err := sess.Query("SELECT 1 from team_member WHERE org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId); err != nil { @@ -213,9 +226,9 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { return m.ErrTeamMemberAlreadyAdded } - if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, cmd.TeamId); err != nil { + if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if len(res) != 1 { + } else if !teamExists { return m.ErrTeamNotFound } @@ -232,18 +245,30 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { }) } +// RemoveTeamMember removes a member from a team func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { + if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + return err + } else if !teamExists { + return m.ErrTeamNotFound + } + var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" - _, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId) + res, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } + rows, err := res.RowsAffected() + if rows == 0 { + return m.ErrTeamMemberNotFound + } return err }) } +// GetTeamMembers return a list of members for the specified team func GetTeamMembers(query *m.GetTeamMembersQuery) error { query.Result = make([]*m.TeamMemberDTO, 0) sess := x.Table("team_member") diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index fb76c3fa9d6..f136411eeba 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -84,13 +84,16 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("Should be able to remove users from a group", func() { + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]}) + So(err, ShouldBeNil) + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]}) So(err, ShouldBeNil) - q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id} - err = GetTeamMembers(q1) + q2 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: group1.Result.Id} + err = GetTeamMembers(q2) So(err, ShouldBeNil) - So(len(q1.Result), ShouldEqual, 0) + So(len(q2.Result), ShouldEqual, 0) }) Convey("Should be able to remove a group with users and permissions", func() { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 73ea07f031f..f42ff5fb2ed 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -315,6 +315,7 @@ func GetUserProfile(query *m.GetUserProfileQuery) error { } query.Result = m.UserProfileDTO{ + Id: user.Id, Name: user.Name, Email: user.Email, Login: user.Login, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 6ce80a69957..5b79e866964 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -88,7 +88,6 @@ var ( ExternalSnapshotUrl string ExternalSnapshotName string ExternalEnabled bool - SnapShotTTLDays int SnapShotRemoveExpired bool // Dashboard history @@ -132,7 +131,8 @@ var ( PluginAppsSkipVerifyTLS bool // Session settings. - SessionOptions session.Options + SessionOptions session.Options + SessionConnMaxLifetime int64 // Global setting objects. Cfg *ini.File @@ -523,7 +523,6 @@ func NewConfigContext(args *CommandLineArgs) error { ExternalSnapshotName = snapshots.Key("external_snapshot_name").String() ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) - SnapShotTTLDays = snapshots.Key("snapshot_TTL_days").MustInt(90) // read dashboard settings dashboards := Cfg.Section("dashboards") @@ -636,6 +635,8 @@ func readSessionConfig() { if SessionOptions.CookiePath == "" { SessionOptions.CookiePath = "/" } + + SessionConnMaxLifetime = Cfg.Section("session").Key("conn_max_lifetime").MustInt64(14400) } func initLogging() { diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index 2411e0006a2..b92d64ad9fc 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -180,6 +180,7 @@ type UserInfoJson struct { func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data UserInfoJson + var err error if s.extractToken(&data, token) != true { response, err := HttpGet(client, s.apiUrl) @@ -193,20 +194,17 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } } - name, err := s.extractName(data) - if err != nil { - return nil, err + name := s.extractName(&data) + + email := s.extractEmail(&data) + if email == "" { + email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } } - email, err := s.extractEmail(data, client) - if err != nil { - return nil, err - } - - login, err := s.extractLogin(data, email) - if err != nil { - return nil, err - } + login := s.extractLogin(&data, email) userInfo := &BasicUserInfo{ Name: name, @@ -251,49 +249,55 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke return false } + email := s.extractEmail(data) + if email == "" { + s.log.Debug("No email found in id_token", "json", string(payload), "data", data) + return false + } + s.log.Debug("Received id_token", "json", string(payload), "data", data) return true } -func (s *SocialGenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) { +func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { if data.Email != "" { - return data.Email, nil + return data.Email } if data.Attributes["email:primary"] != nil { - return data.Attributes["email:primary"][0], nil + return data.Attributes["email:primary"][0] } if data.Upn != "" { emailAddr, emailErr := mail.ParseAddress(data.Upn) if emailErr == nil { - return emailAddr.Address, nil + return emailAddr.Address } } - return s.FetchPrivateEmail(client) + return "" } -func (s *SocialGenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) { +func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string { if data.Login != "" { - return data.Login, nil + return data.Login } if data.Username != "" { - return data.Username, nil + return data.Username } - return email, nil + return email } -func (s *SocialGenericOAuth) extractName(data UserInfoJson) (string, error) { +func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string { if data.Name != "" { - return data.Name, nil + return data.Name } if data.DisplayName != "" { - return data.DisplayName, nil + return data.DisplayName } - return "", nil + return "" } diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index c74e8825bc1..815c684cf03 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -195,10 +195,9 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { - Id int `json:"id"` - Login string `json:"login"` - Email string `json:"email"` - OrganizationsUrl string `json:"organizations_url"` + Id int `json:"id"` + Login string `json:"login"` + Email string `json:"email"` } response, err := HttpGet(client, s.apiUrl) @@ -210,18 +209,20 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi if err != nil { return nil, fmt.Errorf("Error getting user info: %s", err) } - data.OrganizationsUrl = s.apiUrl + "/user/orgs" + userInfo := &BasicUserInfo{ Name: data.Login, Login: data.Login, Email: data.Email, } + organizationsUrl := fmt.Sprintf(s.apiUrl + "/orgs") + if !s.IsTeamMember(client) { return nil, ErrMissingTeamMembership } - if !s.IsOrganizationMember(client, data.OrganizationsUrl) { + if !s.IsOrganizationMember(client, organizationsUrl) { return nil, ErrMissingOrganizationMembership } diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index d5bdd010269..3879dce4ea6 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -152,8 +152,6 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl MetricName: aws.String(query.MetricName), Dimensions: query.Dimensions, Period: aws.Int64(int64(query.Period)), - StartTime: aws.Time(startTime), - EndTime: aws.Time(endTime), } if len(query.Statistics) > 0 { params.Statistics = query.Statistics @@ -162,15 +160,36 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl params.ExtendedStatistics = query.ExtendedStatistics } - if setting.Env == setting.DEV { - plog.Debug("CloudWatch query", "raw query", params) + // 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") } + var resp *cloudwatch.GetMetricStatisticsOutput + for startTime.Before(endTime) { + params.StartTime = aws.Time(startTime) + if query.HighResolution { + startTime = startTime.Add(time.Duration(1440*query.Period) * time.Second) + } else { + startTime = endTime + } + params.EndTime = aws.Time(startTime) - resp, err := client.GetMetricStatisticsWithContext(ctx, params, request.WithResponseReadTimeout(10*time.Second)) - if err != nil { - return nil, err + if setting.Env == setting.DEV { + plog.Debug("CloudWatch query", "raw query", params) + } + + partResp, err := client.GetMetricStatisticsWithContext(ctx, params, request.WithResponseReadTimeout(10*time.Second)) + if err != nil { + return nil, err + } + if resp != nil { + resp.Datapoints = append(resp.Datapoints, partResp.Datapoints...) + } else { + resp = partResp + + } + metrics.M_Aws_CloudWatch_GetMetricStatistics.Inc() } - metrics.M_Aws_CloudWatch_GetMetricStatistics.Inc() queryRes, err := parseResponse(resp, query) if err != nil { @@ -274,6 +293,8 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { alias = "{{metric}}_{{stat}}" } + highResolution := model.Get("highResolution").MustBool(false) + return &CloudWatchQuery{ Region: region, Namespace: namespace, @@ -283,6 +304,7 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { ExtendedStatistics: aws.StringSlice(extendedStatistics), Period: period, Alias: alias, + HighResolution: highResolution, }, nil } diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 5c322a44d56..719edba08ba 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -31,6 +31,7 @@ func TestCloudWatch(t *testing.T) { "p90.00" ], "period": "60", + "highResolution": false, "alias": "{{metric}}_{{stat}}" } ` diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 251527ab4e5..c82cff390c3 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -98,11 +98,13 @@ func init() { "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"}, "AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"}, "AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"}, + "AWS/States": {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"}, "AWS/StorageGateway": {"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "CloudBytesDownloaded", "CloudDownloadLatency", "CloudBytesUploaded", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "QueuedWrites", "ReadBytes", "ReadTime", "TotalCacheSize", "WriteBytes", "WriteTime", "TimeSinceLastRecoveryPoint", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "ReadBytes", "ReadTime", "WriteBytes", "WriteTime", "QueuedWrites"}, "AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut", "ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"}, "AWS/VPN": {"TunnelState", "TunnelDataIn", "TunnelDataOut"}, + "Rekognition": {"SuccessfulRequestCount", "ThrottledCount", "ResponseTime", "DetectedFaceCount", "DetectedLabelCount", "ServerErrorCount", "UserErrorCount"}, "WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"}, "AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"}, "KMS": {"SecondsUntilKeyMaterialExpiration"}, @@ -145,9 +147,11 @@ func init() { "AWS/SES": {}, "AWS/SNS": {"Application", "Platform", "TopicName"}, "AWS/SQS": {"QueueName"}, + "AWS/States": {"StateMachineArn", "ActivityArn", "LambdaFunctionArn"}, "AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"}, "AWS/SWF": {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"}, "AWS/VPN": {"VpnId", "TunnelIpAddress"}, + "Rekognition": {}, "WAF": {"Rule", "WebACL"}, "AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"}, "KMS": {"KeyId"}, diff --git a/pkg/tsdb/cloudwatch/types.go b/pkg/tsdb/cloudwatch/types.go index c2a5ab8c3d7..0737b64686d 100644 --- a/pkg/tsdb/cloudwatch/types.go +++ b/pkg/tsdb/cloudwatch/types.go @@ -13,4 +13,5 @@ type CloudWatchQuery struct { ExtendedStatistics []*string Period int Alias string + HighResolution bool } diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 73b173813af..2960ba0edc4 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -28,12 +28,9 @@ func NewGraphiteExecutor(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, return &GraphiteExecutor{}, nil } -var ( - glog log.Logger -) +var glog = log.New("tsdb.graphite") func init() { - glog = log.New("tsdb.graphite") tsdb.RegisterTsdbQueryEndpoint("graphite", NewGraphiteExecutor) } @@ -52,6 +49,7 @@ func (e *GraphiteExecutor) Query(ctx context.Context, dsInfo *models.DataSource, } for _, query := range tsdbQuery.Queries { + glog.Info("graphite", "query", query.Model) if fullTarget, err := query.Model.Get("targetFull").String(); err == nil { target = fixIntervalFormat(fullTarget) } else { @@ -79,6 +77,9 @@ func (e *GraphiteExecutor) Query(ctx context.Context, dsInfo *models.DataSource, span.SetTag("target", target) span.SetTag("from", from) span.SetTag("until", until) + span.SetTag("datasource_id", dsInfo.Id) + span.SetTag("org_id", dsInfo.OrgId) + defer span.Finish() opentracing.GlobalTracer().Inject( diff --git a/pkg/tsdb/influxdb/query.go b/pkg/tsdb/influxdb/query.go index 499f446e9f0..0a16a507877 100644 --- a/pkg/tsdb/influxdb/query.go +++ b/pkg/tsdb/influxdb/query.go @@ -70,7 +70,7 @@ func (query *Query) renderTags() []string { } else if tag.Operator == "<" || tag.Operator == ">" { textValue = tag.Value } else { - textValue = fmt.Sprintf("'%s'", tag.Value) + textValue = fmt.Sprintf("'%s'", strings.Replace(tag.Value, `\`, `\\`, -1)) } res = append(res, fmt.Sprintf(`%s"%s" %s %s`, str, tag.Key, tag.Operator, textValue)) diff --git a/pkg/tsdb/influxdb/query_test.go b/pkg/tsdb/influxdb/query_test.go index 4a620539a26..f1270560269 100644 --- a/pkg/tsdb/influxdb/query_test.go +++ b/pkg/tsdb/influxdb/query_test.go @@ -170,6 +170,12 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'value'`) }) + Convey("can escape backslashes when rendering string tags", func() { + query := &Query{Tags: []*Tag{{Operator: "=", Value: `C:\test\`, Key: "key"}}} + + So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'C:\\test\\'`) + }) + Convey("can render regular measurement", func() { query := &Query{Measurement: `apa`, Policy: "policy"} diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go new file mode 100644 index 00000000000..9d41cd03255 --- /dev/null +++ b/pkg/tsdb/mssql/macros.go @@ -0,0 +1,124 @@ +package mssql + +import ( + "fmt" + "regexp" + "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 +} + +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 + rExp, _ := regexp.Compile(sExpr) + var macroError error + + sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + args := strings.Split(groups[2], ",") + for i, arg := range args { + args[i] = strings.Trim(arg, " ") + } + res, err := m.evaluateMacro(groups[1], args) + if err != nil && macroError == nil { + macroError = err + return "macro_error()" + } + return res + }) + + if macroError != nil { + return "", macroError + } + + return sql, nil +} + +func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { + result := "" + lastIndex := 0 + + for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { + groups := []string{} + for i := 0; i < len(v); i += 2 { + groups = append(groups, str[v[i]:v[i+1]]) + } + + result += str[lastIndex:v[0]] + repl(groups) + lastIndex = v[1] + } + + return result + str[lastIndex:] +} + +func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { + switch name { + case "__time": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("%s AS time", args[0]), nil + case "__timeEpoch": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("DATEDIFF(second, '1970-01-01', %s) AS time", args[0]), nil + case "__timeFilter": + 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], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__timeFrom": + return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + case "__timeTo": + return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__timeGroup": + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval", 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 { + 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) + } + } + return fmt.Sprintf("CAST(ROUND(DATEDIFF(second, '1970-01-01', %s)/%.1f, 0) as bigint)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil + 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], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__unixEpochFrom": + return fmt.Sprintf("%d", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + case "__unixEpochTo": + return fmt.Sprintf("%d", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + default: + return "", fmt.Errorf("Unknown macro %v", name) + } +} diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go new file mode 100644 index 00000000000..12a9b0d82be --- /dev/null +++ b/pkg/tsdb/mssql/macros_test.go @@ -0,0 +1,124 @@ +package mssql + +import ( + "testing" + + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb" + . "github.com/smartystreets/goconvey/convey" +) + +func TestMacroEngine(t *testing.T) { + Convey("MacroEngine", t, func() { + engine := &MsSqlMacroEngine{} + timeRange := &tsdb.TimeRange{From: "5m", To: "now"} + query := &tsdb.Query{ + Model: simplejson.New(), + } + + Convey("interpolate __time function", func() { + sql, err := engine.Interpolate(query, nil, "select $__time(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select time_column AS time") + }) + + Convey("interpolate __timeEpoch function", func() { + sql, err := engine.Interpolate(query, nil, "select $__timeEpoch(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select DATEDIFF(second, '1970-01-01', time_column) AS time") + }) + + Convey("interpolate __timeEpoch function wrapped in aggregation", func() { + sql, err := engine.Interpolate(query, nil, "select min($__timeEpoch(time_column))") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select min(DATEDIFF(second, '1970-01-01', time_column) AS time)") + }) + + Convey("interpolate __timeFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "WHERE time_column >= DATEADD(s, 18446744066914186738, '1970-01-01') AND time_column <= DATEADD(s, 18446744066914187038, '1970-01-01')") + }) + + Convey("interpolate __timeGroup function", func() { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(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") + }) + + Convey("interpolate __timeGroup function with spaces around arguments", func() { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(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") + }) + + 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() + fillInterval := query.Model.Get("fillInterval").MustInt() + + So(err, ShouldBeNil) + So(fill, ShouldBeTrue) + So(fillNull, ShouldBeTrue) + So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) + }) + + Convey("interpolate __timeGroup function with fill (value = float)", func() { + _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)") + + fill := query.Model.Get("fill").MustBool() + fillValue := query.Model.Get("fillValue").MustFloat64() + fillInterval := query.Model.Get("fillInterval").MustInt() + + So(err, ShouldBeNil) + So(fill, ShouldBeTrue) + So(fillValue, ShouldEqual, 1.5) + So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select DATEADD(second, 18446744066914186738, '1970-01-01')") + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select DATEADD(second, 18446744066914187038, '1970-01-01')") + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select time_column >= 18446744066914186738 AND time_column <= 18446744066914187038") + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 18446744066914186738") + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 18446744066914187038") + }) + }) +} diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go new file mode 100644 index 00000000000..2638fd8bb40 --- /dev/null +++ b/pkg/tsdb/mssql/mssql.go @@ -0,0 +1,323 @@ +package mssql + +import ( + "container/list" + "context" + "database/sql" + "fmt" + "strconv" + "strings" + + "time" + + "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) +} + +func NewMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + endpoint := &MssqlQueryEndpoint{ + log: log.New("tsdb.mssql"), + } + + endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ + MacroEngine: NewMssqlMacroEngine(), + } + + cnnstr := generateConnectionString(datasource) + endpoint.log.Debug("getEngine", "connection", cnnstr) + + if err := endpoint.sqlEngine.InitEngine("mssql", datasource, cnnstr); err != nil { + return nil, err + } + + return endpoint, nil +} + +func generateConnectionString(datasource *models.DataSource) string { + password := "" + for key, value := range datasource.SecureJsonData.Decrypt() { + if key == "password" { + password = value + break + } + } + + hostParts := strings.Split(datasource.Url, ":") + if len(hostParts) < 2 { + hostParts = append(hostParts, "1433") + } + + server, port := hostParts[0], hostParts[1] + return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", + server, + port, + datasource.Database, + datasource.User, + password, + ) +} + +// 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) +} + +func (e MssqlQueryEndpoint) 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 + } + + 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) + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, err + } + + // convert types not handled by denisenkom/go-mssqldb + // unhandled types are returned as []byte + for i := 0; i < len(types); i++ { + if value, ok := values[i].([]byte); ok == true { + switch types[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) + } + default: + e.log.Debug("Rows", "Unknown database type", types[i].DatabaseTypeName(), "value", value) + values[i] = string(value) + } + } + } + + 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) == 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 + } + + switch columnValue := values[timeIndex].(type) { + case int64: + timestamp = float64(columnValue * 1000) + case float64: + timestamp = columnValue * 1000 + case time.Time: + timestamp = (float64(columnValue.Unix()) * 1000) + float64(columnValue.Nanosecond()/1e6) // in case someone is trying to map times beyond 2262 :D + default: + return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp") + } + + if metricIndex >= 0 { + if columnValue, ok := values[metricIndex].(string); ok == true { + 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 + } + + switch columnValue := values[i].(type) { + case int64: + value = null.FloatFrom(float64(columnValue)) + case float64: + value = null.FloatFrom(columnValue) + case nil: + value.Valid = false + default: + return fmt.Errorf("Value column must have numeric datatype, column: %s type: %T value: %v", col, columnValue, columnValue) + } + if metricIndex == -1 { + metric = col + } + + series, exist := pointsBySeries[metric] + if exist == false { + series = &tsdb.TimeSeries{Name: metric} + pointsBySeries[metric] = series + seriesByQueryOrder.PushBack(metric) + } + + if fillMissing { + var intervalStart float64 + if exist == false { + 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 new file mode 100644 index 00000000000..4bd1e3a8ad7 --- /dev/null +++ b/pkg/tsdb/mssql/mssql_test.go @@ -0,0 +1,876 @@ +package mssql + +import ( + "fmt" + "math/rand" + "strings" + "testing" + "time" + + "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/tsdb" + . "github.com/smartystreets/goconvey/convey" +) + +// To run this test, remove the Skip from SkipConvey +// and set up 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. +// Thers's also a dashboard.json in same directory that you can import to Grafana +// once you've created a datasource for the test server/database. +// If needed, change the variable below to the IP address of the database. +var serverIP string = "localhost" + +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"), + } + + sess := x.NewSession() + defer sess.Close() + + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + + Convey("Given a table with different native data types", func() { + sql := ` + IF OBJECT_ID('dbo.[mssql_types]', 'U') IS NOT NULL + DROP TABLE dbo.[mssql_types] + + CREATE TABLE [mssql_types] ( + c_bit bit, + + c_tinyint tinyint, + c_smallint smallint, + c_int int, + c_bigint bigint, + + c_money money, + c_smallmoney smallmoney, + c_numeric numeric(10,5), + c_real real, + c_decimal decimal(10,2), + c_float float, + + c_char char(10), + c_varchar varchar(10), + c_text text, + + c_nchar nchar(12), + c_nvarchar nvarchar(12), + c_ntext ntext, + + c_datetime datetime, + c_datetime2 datetime2, + c_smalldatetime smalldatetime, + c_date date, + c_time time, + c_datetimeoffset datetimeoffset + ) + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + dtFormat := "2006-01-02 15:04:05.999999999" + d := dt.Format(dtFormat) + dt2 := time.Date(2018, 3, 14, 21, 20, 6, 8896406e2, time.UTC) + dt2Format := "2006-01-02 15:04:05.999999999 -07:00" + d2 := dt2.Format(dt2Format) + + sql = fmt.Sprintf(` + INSERT INTO [mssql_types] + SELECT + 1, 5, 20020, 980300, 1420070400, '$20000.15', '£2.15', 12345.12, + 1.11, 2.22, 3.33, + 'char10', 'varchar10', 'text', + N'☺nchar12☺', N'☺nvarchar12☺', N'☺text☺', + CAST('%s' AS DATETIME), CAST('%s' AS DATETIME2), CAST('%s' AS SMALLDATETIME), CAST('%s' AS DATE), CAST('%s' AS TIME), SWITCHOFFSET(CAST('%s' AS DATETIMEOFFSET), '-07:00') + `, d, d2, d, d, d, d2) + + _, err = sess.Exec(sql) + So(err, ShouldBeNil) + + Convey("When doing a table query should map MSSQL column types to Go types", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT * FROM mssql_types", + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["A"] + So(err, ShouldBeNil) + + column := queryResult.Tables[0].Rows[0] + + So(column[0].(bool), ShouldEqual, true) + + So(column[1].(int64), ShouldEqual, 5) + So(column[2].(int64), ShouldEqual, 20020) + So(column[3].(int64), ShouldEqual, 980300) + So(column[4].(int64), ShouldEqual, 1420070400) + + So(column[5].(float64), ShouldEqual, 20000.15) + So(column[6].(float64), ShouldEqual, 2.15) + So(column[7].(float64), ShouldEqual, 12345.12) + So(column[8].(float64), ShouldEqual, 1.1100000143051147) + So(column[9].(float64), ShouldEqual, 2.22) + So(column[10].(float64), ShouldEqual, 3.33) + + So(column[11].(string), ShouldEqual, "char10 ") + So(column[12].(string), ShouldEqual, "varchar10") + So(column[13].(string), ShouldEqual, "text") + + So(column[14].(string), ShouldEqual, "☺nchar12☺ ") + So(column[15].(string), ShouldEqual, "☺nvarchar12☺") + So(column[16].(string), ShouldEqual, "☺text☺") + + So(column[17].(time.Time), ShouldEqual, dt) + So(column[18].(time.Time), ShouldEqual, dt2) + So(column[19].(time.Time), ShouldEqual, dt.Truncate(time.Minute)) + So(column[20].(time.Time), ShouldEqual, dt.Truncate(24*time.Hour)) + So(column[21].(time.Time), ShouldEqual, time.Date(1, 1, 1, dt.Hour(), dt.Minute(), dt.Second(), dt.Nanosecond(), time.UTC)) + So(column[22].(time.Time), ShouldEqual, dt2.In(time.FixedZone("UTC", int(-7*time.Hour)))) + }) + }) + + Convey("Given a table with metrics that lacks data for some series ", func() { + sql := ` + IF OBJECT_ID('dbo.[metric]', 'U') IS NOT NULL + DROP TABLE dbo.[metric] + + CREATE TABLE [metric] ( + time datetime, + value int + ) + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + type metric struct { + Time time.Time + Value int64 + } + + series := []*metric{} + firstRange := genTimeRangeByInterval(fromStart, 10*time.Minute, 10*time.Second) + secondRange := genTimeRangeByInterval(fromStart.Add(20*time.Minute), 10*time.Minute, 10*time.Second) + + for _, t := range firstRange { + series = append(series, &metric{ + Time: t, + Value: 15, + }) + } + + for _, t := range secondRange { + series = append(series, &metric{ + Time: t, + Value: 20, + }) + } + + for _, s := range series { + _, err = sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query using timeGroup", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY $__timeGroup(time, '5m') 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) + + points := queryResult.Series[0].Points + So(len(points), ShouldEqual, 6) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 3; 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) + } + }) + + Convey("When doing a metric query using timeGroup with NULL fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric GROUP BY $__timeGroup(time, '5m') 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(len(points), ShouldEqual, 7) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + So(points[3][0].Valid, ShouldBeFalse) + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 4; i < 7; 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) + } + }) + + Convey("When doing a metric query using timeGroup with float fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', 1.5) AS time, avg(value) as value FROM metric GROUP BY $__timeGroup(time, '5m') 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[3][0].Float64, ShouldEqual, 1.5) + }) + }) + + Convey("Given a table with metrics having multiple values and measurements", func() { + type metric_values struct { + Time time.Time + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` + } + + if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(metric_values{}) + } + err := sess.CreateTable(metric_values{}) + So(err, ShouldBeNil) + + rand.Seed(time.Now().Unix()) + rnd := func(min, max int64) int64 { + return rand.Int63n(max-min) + min + } + + series := []*metric_values{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric B", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + } + + for _, s := range series { + _, err = sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne 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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one") + So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") + }) + + Convey("When doing a metric query grouping by time should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeEpoch(time), 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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "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 + DROP PROCEDURE sp_test_epoch + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + sql = ` + CREATE PROCEDURE sp_test_epoch( + @from int, + @to int, + @interval nvarchar(50) = '5m', + @metric nvarchar(200) = 'ALL' + ) AS + BEGIN + DECLARE @dInterval int + SELECT @dInterval = 300 + + IF @interval = '10m' + SELECT @dInterval = 600 + + 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 + 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 + ORDER BY 1 + END + ` + + _, err = sess.Exec(sql) + So(err, ShouldBeNil) + + Convey("When doing a metric query using stored procedure should return correct result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `DECLARE + @from int = $__unixEpochFrom(), + @to int = $__unixEpochTo() + + EXEC dbo.sp_test_epoch @from, @to`, + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: "1521117000000", + To: "1521122100000", + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["A"] + So(err, ShouldBeNil) + fmt.Println("query", "sql", queryResult.Meta) + 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") + }) + }) + + Convey("Given a stored procedure that takes @from and @to in datetime", func() { + sql := ` + IF object_id('sp_test_datetime') IS NOT NULL + DROP PROCEDURE sp_test_datetime + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + sql = ` + CREATE PROCEDURE sp_test_datetime( + @from datetime, + @to datetime, + @interval nvarchar(50) = '5m', + @metric nvarchar(200) = 'ALL' + ) AS + BEGIN + DECLARE @dInterval int + SELECT @dInterval = 300 + + IF @interval = '10m' + SELECT @dInterval = 600 + + 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 + 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 + ORDER BY 1 + END + ` + + _, err = sess.Exec(sql) + So(err, ShouldBeNil) + + Convey("When doing a metric query using stored procedure should return correct result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `DECLARE + @from int = $__unixEpochFrom(), + @to int = $__unixEpochTo() + + EXEC dbo.sp_test_epoch @from, @to`, + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: "1521117000000", + To: "1521122100000", + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["A"] + So(err, ShouldBeNil) + 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") + }) + }) + }) + + Convey("Given a table with event data", func() { + sql := ` + IF OBJECT_ID('dbo.[event]', 'U') IS NOT NULL + DROP TABLE dbo.[event] + + CREATE TABLE [event] ( + time_sec int, + description nvarchar(100), + tags nvarchar(100), + ) + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + type event struct { + TimeSec int64 + Description string + Tags string + } + + events := []*event{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + events = append(events, &event{ + TimeSec: t.Unix(), + Description: "Someone deployed something", + Tags: "deploy", + }) + events = append(events, &event{ + TimeSec: t.Add(5 * time.Minute).Unix(), + Description: "New support ticket registered", + Tags: "ticket", + }) + } + + for _, e := range events { + sql = fmt.Sprintf(` + INSERT [event] (time_sec, description, tags) + VALUES(%d, '%s', '%s') + `, e.TimeSec, e.Description, e.Tags) + + _, err = sess.Exec(sql) + So(err, ShouldBeNil) + } + + Convey("When doing an annotation query of deploy events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT time_sec as time, description as [text], tags FROM [event] WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC", + "format": "table", + }), + RefId: "Deploys", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Deploys"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query of ticket events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT time_sec as time, description as [text], tags FROM [event] WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC", + "format": "table", + }), + RefId: "Tickets", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Tickets"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query with a time column in datetime format", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + dtFormat := "2006-01-02 15:04:05.999999999" + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + CAST('%s' AS DATETIME) as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Format(dtFormat)), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch second format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + cast(%d as int) as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()*1000), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column holding a bigint null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as bigint) as time, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) + + Convey("When doing an annotation query with a time column holding a datetime null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as datetime) as time, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) + }) + }) +} + +func InitMSSQLTestDB(t *testing.T) *xorm.Engine { + x, err := xorm.NewEngine(sqlutil.TestDB_Mssql.DriverName, strings.Replace(sqlutil.TestDB_Mssql.ConnStr, "localhost", serverIP, 1)) + x.DatabaseTZ = time.UTC + x.TZLocation = time.UTC + + // x.ShowSQL() + + if err != nil { + t.Fatalf("Failed to init mssql db %v", err) + } + + return x +} + +func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { + durationSec := int64(duration.Seconds()) + intervalSec := int64(interval.Seconds()) + timeRange := []time.Time{} + + for i := int64(0); i < durationSec; i += intervalSec { + timeRange = append(timeRange, from) + from = from.Add(time.Duration(int64(time.Second) * intervalSec)) + } + + return timeRange +} diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 108b81fc5f3..a292f209429 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -3,6 +3,7 @@ package mysql import ( "fmt" "regexp" + "strconv" "strings" "time" @@ -15,19 +16,25 @@ const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type MySqlMacroEngine struct { TimeRange *tsdb.TimeRange + Query *tsdb.Query } func NewMysqlMacroEngine() tsdb.SqlMacroEngine { return &MySqlMacroEngine{} } -func (m *MySqlMacroEngine) Interpolate(timeRange *tsdb.TimeRange, sql string) (string, error) { +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 sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { - res, err := m.evaluateMacro(groups[1], strings.Split(groups[2], ",")) + args := strings.Split(groups[2], ",") + for i, arg := range args { + args[i] = strings.Trim(arg, " ") + } + res, err := m.evaluateMacro(groups[1], args) if err != nil && macroError == nil { macroError = err return "macro_error()" @@ -61,7 +68,7 @@ func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { - case "__time": + case "__timeEpoch", "__time": if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } @@ -76,13 +83,26 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er case "__timeTo": return fmt.Sprintf("FROM_UNIXTIME(%d)", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil case "__timeGroup": - if len(args) != 2 { + if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) } - interval, err := time.ParseDuration(strings.Trim(args[1], `'" `)) + interval, err := time.ParseDuration(strings.Trim(args[1], `'"`)) if err != nil { 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) + } + } return fmt.Sprintf("cast(cast(UNIX_TIMESTAMP(%s)/(%.0f) as signed)*%.0f as signed)", args[0], interval.Seconds(), interval.Seconds()), nil case "__unixEpochFilter": if len(args) == 0 { diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 988612fb287..a89ba16ab78 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -10,31 +10,32 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { engine := &MySqlMacroEngine{} + query := &tsdb.Query{} timeRange := &tsdb.TimeRange{From: "5m", To: "now"} Convey("interpolate __time function", func() { - sql, err := engine.Interpolate(nil, "select $__time(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__time(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select UNIX_TIMESTAMP(time_column) as time_sec") }) Convey("interpolate __time function wrapped in aggregation", func() { - sql, err := engine.Interpolate(nil, "select min($__time(time_column))") + sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") So(err, ShouldBeNil) So(sql, ShouldEqual, "select min(UNIX_TIMESTAMP(time_column) as time_sec)") }) Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(timeRange, "WHERE $__timeFilter(time_column)") + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "WHERE time_column >= FROM_UNIXTIME(18446744066914186738) AND time_column <= FROM_UNIXTIME(18446744066914187038)") }) Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(timeRange, "select $__timeFrom(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select FROM_UNIXTIME(18446744066914186738)") @@ -42,35 +43,43 @@ func TestMacroEngine(t *testing.T) { Convey("interpolate __timeGroup function", func() { - sql, err := engine.Interpolate(timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)") + }) + + Convey("interpolate __timeGroup function with spaces around arguments", func() { + + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") So(err, ShouldBeNil) So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)") }) Convey("interpolate __timeTo function", func() { - sql, err := engine.Interpolate(timeRange, "select $__timeTo(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select FROM_UNIXTIME(18446744066914187038)") }) Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochFilter(18446744066914186738)") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(18446744066914186738)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738 >= 18446744066914186738 AND 18446744066914186738 <= 18446744066914187038") }) Convey("interpolate __unixEpochFrom function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochFrom()") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738") }) Convey("interpolate __unixEpochTo function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochTo()") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914187038") diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index cf965b17a08..483974c55a4 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "fmt" + "math" "reflect" "strconv" "time" @@ -56,7 +57,7 @@ func (e *MysqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSourc return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) } -func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { +func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { columnNames, err := rows.Columns() columnCount := len(columnNames) @@ -80,7 +81,7 @@ func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, // check if there is a column named time for i, col := range columnNames { switch col { - case "time_sec": + case "time", "time_sec": timeIndex = i } } @@ -95,13 +96,10 @@ func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, return err } - // for annotations, convert to epoch - if timeIndex != -1 { - switch value := values[timeIndex].(type) { - case time.Time: - values[timeIndex] = float64(value.UnixNano() / 1e9) - } - } + // 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) } @@ -175,7 +173,7 @@ 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) error { +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() @@ -184,103 +182,152 @@ func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. return err } - rowData := NewStringStringScan(columnNames) + 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) == false { + fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() + fillValue.Valid = true + } + } + + for rows.Next() { + var timestamp float64 + var value null.Float + var metric string - for ; rows.Next(); rowCount++ { if rowCount > rowLimit { - return fmt.Errorf("MySQL query row limit exceeded, limit %d", rowLimit) + return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) } - err := rowData.Update(rows.Rows) + values, err := e.getTypedRowData(rows) if err != nil { - e.log.Error("MySQL response parsing", "error", err) - return fmt.Errorf("MySQL response parsing error %v", err) + return err } - if rowData.metric == "" { - rowData.metric = "Unknown" + switch columnValue := values[timeIndex].(type) { + case int64: + timestamp = float64(columnValue * 1000) + case float64: + timestamp = columnValue * 1000 + case time.Time: + timestamp = float64(columnValue.UnixNano() / 1e6) + default: + return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) } - if !rowData.time.Valid { - return fmt.Errorf("Found row with no time value") + if metricIndex >= 0 { + if columnValue, ok := values[metricIndex].(string); ok == true { + metric = columnValue + } else { + return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) + } } - if series, exist := pointsBySeries[rowData.metric]; exist { - series.Points = append(series.Points, tsdb.TimePoint{rowData.value, rowData.time}) - } else { - series := &tsdb.TimeSeries{Name: rowData.metric} - series.Points = append(series.Points, tsdb.TimePoint{rowData.value, rowData.time}) - pointsBySeries[rowData.metric] = series - seriesByQueryOrder.PushBack(rowData.metric) + for i, col := range columnNames { + if i == timeIndex || i == metricIndex { + continue + } + + switch columnValue := values[i].(type) { + case int64: + value = null.FloatFrom(float64(columnValue)) + case float64: + value = null.FloatFrom(columnValue) + case nil: + value.Valid = false + default: + return fmt.Errorf("Value column must have numeric datatype, column: %s type: %T value: %v", col, columnValue, columnValue) + } + if metricIndex == -1 { + metric = col + } + + series, exist := pointsBySeries[metric] + if exist == false { + series = &tsdb.TimeSeries{Name: metric} + pointsBySeries[metric] = series + seriesByQueryOrder.PushBack(metric) + } + + if fillMissing { + var intervalStart float64 + if exist == false { + 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 } - -type stringStringScan struct { - rowPtrs []interface{} - rowValues []string - columnNames []string - columnCount int - - time null.Float - value null.Float - metric string -} - -func NewStringStringScan(columnNames []string) *stringStringScan { - s := &stringStringScan{ - columnCount: len(columnNames), - columnNames: columnNames, - rowPtrs: make([]interface{}, len(columnNames)), - rowValues: make([]string, len(columnNames)), - } - - for i := 0; i < s.columnCount; i++ { - s.rowPtrs[i] = new(sql.RawBytes) - } - - return s -} - -func (s *stringStringScan) Update(rows *sql.Rows) error { - if err := rows.Scan(s.rowPtrs...); err != nil { - return err - } - - s.time = null.FloatFromPtr(nil) - s.value = null.FloatFromPtr(nil) - - for i := 0; i < s.columnCount; i++ { - if rb, ok := s.rowPtrs[i].(*sql.RawBytes); ok { - s.rowValues[i] = string(*rb) - - switch s.columnNames[i] { - case "time_sec": - if sec, err := strconv.ParseInt(s.rowValues[i], 10, 64); err == nil { - s.time = null.FloatFrom(float64(sec * 1000)) - } - case "value": - if value, err := strconv.ParseFloat(s.rowValues[i], 64); err == nil { - s.value = null.FloatFrom(value) - } - case "metric": - s.metric = s.rowValues[i] - } - - *rb = nil // reset pointer to discard current value to avoid a bug - } else { - return fmt.Errorf("Cannot convert index %d column %s to type *sql.RawBytes", i, s.columnNames[i]) - } - } - return nil -} diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index fe2c82223d2..750704c9965 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -1,6 +1,8 @@ package mysql import ( + "fmt" + "math/rand" "testing" "time" @@ -14,6 +16,10 @@ import ( // To run this test, remove the Skip from SkipConvey // and set up a MySQL db named grafana_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. +// Thers's also a dashboard.json in same directory that you can import to Grafana +// once you've created a datasource for the test server/database. func TestMySQL(t *testing.T) { SkipConvey("MySQL", t, func() { x := InitMySQLTestDB(t) @@ -29,110 +35,621 @@ func TestMySQL(t *testing.T) { sess := x.NewSession() defer sess.Close() - sql := "CREATE TABLE `mysql_types` (" - sql += "`atinyint` tinyint(1) NOT NULL," - sql += "`avarchar` varchar(3) NOT NULL," - sql += "`achar` char(3)," - sql += "`amediumint` mediumint NOT NULL," - sql += "`asmallint` smallint NOT NULL," - sql += "`abigint` bigint NOT NULL," - sql += "`aint` int(11) NOT NULL," - sql += "`adouble` double(10,2)," - sql += "`anewdecimal` decimal(10,2)," - sql += "`afloat` float(10,2) NOT NULL," - sql += "`atimestamp` timestamp NOT NULL," - sql += "`adatetime` datetime NOT NULL," - sql += "`atime` time NOT NULL," - // sql += "`ayear` year," // Crashes xorm when running cleandb - sql += "`abit` bit(1)," - sql += "`atinytext` tinytext," - sql += "`atinyblob` tinyblob," - sql += "`atext` text," - sql += "`ablob` blob," - sql += "`amediumtext` mediumtext," - sql += "`amediumblob` mediumblob," - sql += "`alongtext` longtext," - sql += "`alongblob` longblob," - sql += "`aenum` enum('val1', 'val2')," - sql += "`aset` set('a', 'b', 'c', 'd')," - sql += "`adate` date," - sql += "`time_sec` datetime(6)," - sql += "`aintnull` int(11)," - sql += "`afloatnull` float(10,2)," - sql += "`avarcharnull` varchar(3)," - sql += "`adecimalnull` decimal(10,2)" - sql += ") ENGINE=InnoDB DEFAULT CHARSET=latin1;" - _, err := sess.Exec(sql) - So(err, ShouldBeNil) + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.Local) - sql = "INSERT INTO `mysql_types` " - sql += "(`atinyint`, `avarchar`, `achar`, `amediumint`, `asmallint`, `abigint`, `aint`, `adouble`, " - sql += "`anewdecimal`, `afloat`, `adatetime`, `atimestamp`, `atime`, `abit`, `atinytext`, " - sql += "`atinyblob`, `atext`, `ablob`, `amediumtext`, `amediumblob`, `alongtext`, `alongblob`, " - sql += "`aenum`, `aset`, `adate`, `time_sec`) " - sql += "VALUES(1, 'abc', 'def', 1, 10, 100, 1420070400, 1.11, " - sql += "2.22, 3.33, now(), current_timestamp(), '11:11:11', 1, 'tinytext', " - sql += "'tinyblob', 'text', 'blob', 'mediumtext', 'mediumblob', 'longtext', 'longblob', " - sql += "'val2', 'a,b', curdate(), '2018-01-01 00:01:01.123456');" - _, err = sess.Exec(sql) - So(err, ShouldBeNil) - - Convey("Query with Table format should map MySQL column types to Go types", func() { - query := &tsdb.TsdbQuery{ - Queries: []*tsdb.Query{ - { - Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": "SELECT * FROM mysql_types", - "format": "table", - }), - RefId: "A", - }, - }, + Convey("Given a table with different native data types", func() { + if exists, err := sess.IsTableExist("mysql_types"); err != nil || exists { + So(err, ShouldBeNil) + sess.DropTable("mysql_types") } - resp, err := endpoint.Query(nil, nil, query) - queryResult := resp.Results["A"] + sql := "CREATE TABLE `mysql_types` (" + sql += "`atinyint` tinyint(1) NOT NULL," + sql += "`avarchar` varchar(3) NOT NULL," + sql += "`achar` char(3)," + sql += "`amediumint` mediumint NOT NULL," + sql += "`asmallint` smallint NOT NULL," + sql += "`abigint` bigint NOT NULL," + sql += "`aint` int(11) NOT NULL," + sql += "`adouble` double(10,2)," + sql += "`anewdecimal` decimal(10,2)," + sql += "`afloat` float(10,2) NOT NULL," + sql += "`atimestamp` timestamp NOT NULL," + sql += "`adatetime` datetime NOT NULL," + sql += "`atime` time NOT NULL," + sql += "`ayear` year," // Crashes xorm when running cleandb + sql += "`abit` bit(1)," + sql += "`atinytext` tinytext," + sql += "`atinyblob` tinyblob," + sql += "`atext` text," + sql += "`ablob` blob," + sql += "`amediumtext` mediumtext," + sql += "`amediumblob` mediumblob," + sql += "`alongtext` longtext," + sql += "`alongblob` longblob," + sql += "`aenum` enum('val1', 'val2')," + sql += "`aset` set('a', 'b', 'c', 'd')," + sql += "`adate` date," + sql += "`time_sec` datetime(6)," + sql += "`aintnull` int(11)," + sql += "`afloatnull` float(10,2)," + sql += "`avarcharnull` varchar(3)," + sql += "`adecimalnull` decimal(10,2)" + sql += ") ENGINE=InnoDB DEFAULT CHARSET=latin1;" + _, err := sess.Exec(sql) So(err, ShouldBeNil) - column := queryResult.Tables[0].Rows[0] + sql = "INSERT INTO `mysql_types` " + sql += "(`atinyint`, `avarchar`, `achar`, `amediumint`, `asmallint`, `abigint`, `aint`, `adouble`, " + sql += "`anewdecimal`, `afloat`, `adatetime`, `atimestamp`, `atime`, `ayear`, `abit`, `atinytext`, " + sql += "`atinyblob`, `atext`, `ablob`, `amediumtext`, `amediumblob`, `alongtext`, `alongblob`, " + sql += "`aenum`, `aset`, `adate`, `time_sec`) " + sql += "VALUES(1, 'abc', 'def', 1, 10, 100, 1420070400, 1.11, " + sql += "2.22, 3.33, now(), current_timestamp(), '11:11:11', '2018', 1, 'tinytext', " + sql += "'tinyblob', 'text', 'blob', 'mediumtext', 'mediumblob', 'longtext', 'longblob', " + sql += "'val2', 'a,b', curdate(), '2018-01-01 00:01:01.123456');" + _, err = sess.Exec(sql) + So(err, ShouldBeNil) - So(*column[0].(*int8), ShouldEqual, 1) - So(column[1].(string), ShouldEqual, "abc") - So(column[2].(string), ShouldEqual, "def") - So(*column[3].(*int32), ShouldEqual, 1) - So(*column[4].(*int16), ShouldEqual, 10) - So(*column[5].(*int64), ShouldEqual, 100) - So(*column[6].(*int32), ShouldEqual, 1420070400) - So(column[7].(float64), ShouldEqual, 1.11) - So(column[8].(float64), ShouldEqual, 2.22) - So(*column[9].(*float32), ShouldEqual, 3.33) - _, offset := time.Now().Zone() - So(column[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second)) - So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second)) - So(column[12].(string), ShouldEqual, "11:11:11") - So(*column[13].(*[]byte), ShouldHaveSameTypeAs, []byte{1}) - So(column[14].(string), ShouldEqual, "tinytext") - So(column[15].(string), ShouldEqual, "tinyblob") - So(column[16].(string), ShouldEqual, "text") - So(column[17].(string), ShouldEqual, "blob") - So(column[18].(string), ShouldEqual, "mediumtext") - So(column[19].(string), ShouldEqual, "mediumblob") - So(column[20].(string), ShouldEqual, "longtext") - So(column[21].(string), ShouldEqual, "longblob") - So(column[22].(string), ShouldEqual, "val2") - So(column[23].(string), ShouldEqual, "a,b") - So(column[24].(time.Time).Format("2006-01-02T00:00:00Z"), ShouldEqual, time.Now().Format("2006-01-02T00:00:00Z")) - So(column[25].(float64), ShouldEqual, 1514764861) - So(column[26], ShouldEqual, nil) - So(column[27], ShouldEqual, nil) - So(column[28], ShouldEqual, "") - So(column[29], ShouldEqual, nil) + Convey("Query with Table format should map MySQL column types to Go types", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT * FROM mysql_types", + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + column := queryResult.Tables[0].Rows[0] + + So(*column[0].(*int8), ShouldEqual, 1) + So(column[1].(string), ShouldEqual, "abc") + So(column[2].(string), ShouldEqual, "def") + So(*column[3].(*int32), ShouldEqual, 1) + So(*column[4].(*int16), ShouldEqual, 10) + So(*column[5].(*int64), ShouldEqual, 100) + So(*column[6].(*int32), ShouldEqual, 1420070400) + So(column[7].(float64), ShouldEqual, 1.11) + So(column[8].(float64), ShouldEqual, 2.22) + So(*column[9].(*float32), ShouldEqual, 3.33) + _, offset := time.Now().Zone() + So(column[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second)) + So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now().Add(time.Duration(offset)*time.Second)) + So(column[12].(string), ShouldEqual, "11:11:11") + So(column[13].(int64), ShouldEqual, 2018) + So(*column[14].(*[]byte), ShouldHaveSameTypeAs, []byte{1}) + So(column[15].(string), ShouldEqual, "tinytext") + So(column[16].(string), ShouldEqual, "tinyblob") + So(column[17].(string), ShouldEqual, "text") + So(column[18].(string), ShouldEqual, "blob") + So(column[19].(string), ShouldEqual, "mediumtext") + So(column[20].(string), ShouldEqual, "mediumblob") + So(column[21].(string), ShouldEqual, "longtext") + So(column[22].(string), ShouldEqual, "longblob") + So(column[23].(string), ShouldEqual, "val2") + So(column[24].(string), ShouldEqual, "a,b") + So(column[25].(time.Time).Format("2006-01-02T00:00:00Z"), ShouldEqual, time.Now().Format("2006-01-02T00:00:00Z")) + So(column[26].(float64), ShouldEqual, float64(1514764861000)) + So(column[27], ShouldEqual, nil) + So(column[28], ShouldEqual, nil) + So(column[29], ShouldEqual, "") + So(column[30], ShouldEqual, nil) + }) + }) + + Convey("Given a table with metrics that lacks data for some series ", func() { + type metric struct { + Time time.Time + Value int64 + } + + if exist, err := sess.IsTableExist(metric{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(metric{}) + } + err := sess.CreateTable(metric{}) + So(err, ShouldBeNil) + + series := []*metric{} + firstRange := genTimeRangeByInterval(fromStart, 10*time.Minute, 10*time.Second) + secondRange := genTimeRangeByInterval(fromStart.Add(20*time.Minute), 10*time.Minute, 10*time.Second) + + for _, t := range firstRange { + series = append(series, &metric{ + Time: t, + Value: 15, + }) + } + + for _, t := range secondRange { + series = append(series, &metric{ + Time: t, + Value: 20, + }) + } + + for _, s := range series { + _, err = sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query using timeGroup", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m') as time_sec, avg(value) as value FROM metric GROUP BY 1 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) + + points := queryResult.Series[0].Points + So(len(points), ShouldEqual, 6) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 3; 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) + } + }) + + Convey("When doing a metric query using timeGroup with NULL fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', NULL) 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(len(points), ShouldEqual, 7) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + So(points[3][0].Valid, ShouldBeFalse) + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 4; i < 7; 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) + } + }) + + Convey("When doing a metric query using timeGroup with float fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', 1.5) 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[3][0].Float64, ShouldEqual, 1.5) + }) + }) + + Convey("Given a table with metrics having multiple values and measurements", func() { + type metric_values struct { + Time time.Time + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` + } + + if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(metric_values{}) + } + err := sess.CreateTable(metric_values{}) + So(err, ShouldBeNil) + + rand.Seed(time.Now().Unix()) + rnd := func(min, max int64) int64 { + return rand.Int63n(max-min) + min + } + + series := []*metric_values{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric B", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + } + + for _, s := range series { + _, err := sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne 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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "Metric B - value one") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A - value one") + }) + + Convey("When doing a metric query grouping by time should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__time(time), 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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "valueTwo") + }) + }) + + Convey("Given a table with event data", func() { + type event struct { + TimeSec int64 + Description string + Tags string + } + + if exist, err := sess.IsTableExist(event{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(event{}) + } + err := sess.CreateTable(event{}) + So(err, ShouldBeNil) + + events := []*event{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + events = append(events, &event{ + TimeSec: t.Unix(), + Description: "Someone deployed something", + Tags: "deploy", + }) + events = append(events, &event{ + TimeSec: t.Add(5 * time.Minute).Unix(), + Description: "New support ticket registered", + Tags: "ticket", + }) + } + + for _, e := range events { + _, err = sess.Insert(e) + So(err, ShouldBeNil) + } + + Convey("When doing an annotation query of deploy events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT time_sec, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC`, + "format": "table", + }), + RefId: "Deploys", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Deploys"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query of ticket events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT time_sec, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC`, + "format": "table", + }), + RefId: "Tickets", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Tickets"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query with a time column in datetime format", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 0, time.UTC) + dtFormat := "2006-01-02 15:04:05.999999999" + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + CAST('%s' as datetime) as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Format(dtFormat)), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch second format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) + }) + + Convey("When doing an annotation query with a time column in epoch second format (signed integer) should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 0, time.Local) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + CAST('%d' as signed integer) as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()*1000), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) + }) + + Convey("When doing an annotation query with a time column holding a unsigned integer null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as unsigned integer) as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) + + Convey("When doing an annotation query with a time column holding a DATETIME null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as DATETIME) as time_sec, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) }) }) } func InitMySQLTestDB(t *testing.T) *xorm.Engine { x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr+"&parseTime=true") + x.DatabaseTZ = time.Local + x.TZLocation = time.Local // x.ShowSQL() @@ -140,7 +657,18 @@ func InitMySQLTestDB(t *testing.T) *xorm.Engine { t.Fatalf("Failed to init mysql db %v", err) } - sqlutil.CleanDB(x) - return x } + +func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { + durationSec := int64(duration.Seconds()) + intervalSec := int64(interval.Seconds()) + timeRange := []time.Time{} + + for i := int64(0); i < durationSec; i += intervalSec { + timeRange = append(timeRange, from) + from = from.Add(time.Duration(int64(time.Second) * intervalSec)) + } + + return timeRange +} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 29daa0c3bb4..692b891eddd 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -22,23 +22,10 @@ import ( ) type OpenTsdbExecutor struct { - //*models.DataSource - //httpClient *http.Client } func NewOpenTsdbExecutor(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - /* - httpClient, err := datasource.GetHttpClient() - - if err != nil { - return nil, err - } - */ - - return &OpenTsdbExecutor{ - //DataSource: datasource, - //httpClient: httpClient, - }, nil + return &OpenTsdbExecutor{}, nil } var ( diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 086eb96655f..23daeebec5a 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -3,6 +3,7 @@ package postgres import ( "fmt" "regexp" + "strconv" "strings" "time" @@ -15,19 +16,25 @@ const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type PostgresMacroEngine struct { TimeRange *tsdb.TimeRange + Query *tsdb.Query } func NewPostgresMacroEngine() tsdb.SqlMacroEngine { return &PostgresMacroEngine{} } -func (m *PostgresMacroEngine) Interpolate(timeRange *tsdb.TimeRange, sql string) (string, error) { +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 { - res, err := m.evaluateMacro(groups[1], strings.Split(groups[2], ",")) + args := strings.Split(groups[2], ",") + for i, arg := range args { + args[i] = strings.Trim(arg, " ") + } + res, err := m.evaluateMacro(groups[1], args) if err != nil && macroError == nil { macroError = err return "macro_error()" @@ -82,13 +89,26 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, case "__timeTo": return fmt.Sprintf("to_timestamp(%d)", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil case "__timeGroup": - if len(args) != 2 { - return "", fmt.Errorf("macro %v needs time column and interval", name) + 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], `' `)) + interval, err := time.ParseDuration(strings.Trim(args[1], `'`)) if err != nil { 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) + } + } return fmt.Sprintf("(extract(epoch from %s)/%v)::bigint*%v AS time", args[0], interval.Seconds(), interval.Seconds()), nil case "__unixEpochFilter": if len(args) == 0 { diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index ebc5191d46e..b18acced963 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/pkg/tsdb/postgres/macros_test.go @@ -10,31 +10,32 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { engine := &PostgresMacroEngine{} + query := &tsdb.Query{} timeRange := &tsdb.TimeRange{From: "5m", To: "now"} Convey("interpolate __time function", func() { - sql, err := engine.Interpolate(nil, "select $__time(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__time(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select time_column AS \"time\"") }) Convey("interpolate __time function wrapped in aggregation", func() { - sql, err := engine.Interpolate(nil, "select min($__time(time_column))") + sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") So(err, ShouldBeNil) So(sql, ShouldEqual, "select min(time_column AS \"time\")") }) Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(timeRange, "WHERE $__timeFilter(time_column)") + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "WHERE extract(epoch from time_column) BETWEEN 18446744066914186738 AND 18446744066914187038") }) Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(timeRange, "select $__timeFrom(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select to_timestamp(18446744066914186738)") @@ -42,35 +43,43 @@ func TestMacroEngine(t *testing.T) { Convey("interpolate __timeGroup function", func() { - sql, err := engine.Interpolate(timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time") + }) + + Convey("interpolate __timeGroup function with spaces between args", func() { + + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") So(err, ShouldBeNil) So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time") }) Convey("interpolate __timeTo function", func() { - sql, err := engine.Interpolate(timeRange, "select $__timeTo(time_column)") + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select to_timestamp(18446744066914187038)") }) Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochFilter(18446744066914186738)") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(18446744066914186738)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738 >= 18446744066914186738 AND 18446744066914186738 <= 18446744066914187038") }) Convey("interpolate __unixEpochFrom function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochFrom()") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738") }) Convey("interpolate __unixEpochTo function", func() { - sql, err := engine.Interpolate(timeRange, "select $__unixEpochTo()") + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914187038") diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index a8c96d8119c..5f6b56ebcf1 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -4,6 +4,7 @@ import ( "container/list" "context" "fmt" + "math" "net/url" "strconv" "time" @@ -53,15 +54,15 @@ func generateConnectionString(datasource *models.DataSource) string { } sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full") - return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", url.PathEscape(datasource.User), url.PathEscape(password), url.PathEscape(datasource.Url), url.PathEscape(datasource.Database), url.QueryEscape(sslmode)) + u := &url.URL{Scheme: "postgres", User: url.UserPassword(datasource.User, password), Host: datasource.Url, Path: datasource.Database, RawQuery: "sslmode=" + 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) } -func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { - +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 @@ -98,14 +99,10 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro return err } - // convert column named time to unix timestamp to make - // native datetime postgres types work in annotation queries - if timeIndex != -1 { - switch value := values[timeIndex].(type) { - case time.Time: - values[timeIndex] = float64(value.UnixNano() / 1e9) - } - } + // 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) } @@ -116,7 +113,6 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro } func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, error) { - types, err := rows.ColumnTypes() if err != nil { return nil, err @@ -157,7 +153,7 @@ 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) error { +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() @@ -198,6 +194,17 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co 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) == false { + fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() + fillValue.Valid = true + } + } + for rows.Next() { var timestamp float64 var value null.Float @@ -220,14 +227,14 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co case time.Time: timestamp = float64(columnValue.UnixNano() / 1e6) default: - return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp") + 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 == true { metric = columnValue } else { - return fmt.Errorf("Column metric must be of type char,varchar or text") + return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) } } @@ -249,7 +256,34 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co if metricIndex == -1 { metric = col } - e.appendTimePoint(pointsBySeries, seriesByQueryOrder, metric, timestamp, value) + + series, exist := pointsBySeries[metric] + if exist == false { + series = &tsdb.TimeSeries{Name: metric} + pointsBySeries[metric] = series + seriesByQueryOrder.PushBack(metric) + } + + if fillMissing { + var intervalStart float64 + if exist == false { + 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++ } @@ -258,20 +292,22 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co 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 } - -func (e PostgresQueryEndpoint) appendTimePoint(pointsBySeries map[string]*tsdb.TimeSeries, seriesByQueryOrder *list.List, metric string, timestamp float64, value null.Float) { - if series, exist := pointsBySeries[metric]; exist { - series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) - } else { - series := &tsdb.TimeSeries{Name: metric} - series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) - pointsBySeries[metric] = series - seriesByQueryOrder.PushBack(metric) - } - e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) -} diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index 75e8cb77f2e..3f2203ac7a4 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -1,6 +1,8 @@ package postgres import ( + "fmt" + "math/rand" "testing" "time" @@ -14,7 +16,11 @@ import ( ) // To run this test, remove the Skip from SkipConvey -// and set up a PostgreSQL db named grafanatest and a user/password grafanatest/grafanatest +// and set up a PostgreSQL db named grafanatest 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. +// Thers's also a dashboard.json in same directory that you can import to Grafana +// once you've created a datasource for the test server/database. func TestPostgres(t *testing.T) { SkipConvey("PostgreSQL", t, func() { x := InitPostgresTestDB(t) @@ -30,88 +36,599 @@ func TestPostgres(t *testing.T) { sess := x.NewSession() defer sess.Close() - sql := ` - CREATE TABLE postgres_types( - c00_smallint smallint, - c01_integer integer, - c02_bigint bigint, + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) - c03_real real, - c04_double double precision, - c05_decimal decimal(10,2), - c06_numeric numeric(10,2), + Convey("Given a table with different native data types", func() { + sql := ` + DROP TABLE IF EXISTS postgres_types; + CREATE TABLE postgres_types( + c00_smallint smallint, + c01_integer integer, + c02_bigint bigint, - c07_char char(10), - c08_varchar varchar(10), - c09_text text, + c03_real real, + c04_double double precision, + c05_decimal decimal(10,2), + c06_numeric numeric(10,2), - c10_timestamp timestamp without time zone, - c11_timestamptz timestamp with time zone, - c12_date date, - c13_time time without time zone, - c14_timetz time with time zone, - c15_interval interval - ); - ` - _, err := sess.Exec(sql) - So(err, ShouldBeNil) + c07_char char(10), + c08_varchar varchar(10), + c09_text text, - sql = ` - INSERT INTO postgres_types VALUES( - 1,2,3, - 4.5,6.7,1.1,1.2, - 'char10','varchar10','text', + c10_timestamp timestamp without time zone, + c11_timestamptz timestamp with time zone, + c12_date date, + c13_time time without time zone, + c14_timetz time with time zone, - now(),now(),now(),now(),now(),'15m'::interval - ); - ` - _, err = sess.Exec(sql) - So(err, ShouldBeNil) - - Convey("Query with Table format should map PostgreSQL column types to Go types", func() { - query := &tsdb.TsdbQuery{ - Queries: []*tsdb.Query{ - { - Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": "SELECT * FROM postgres_types", - "format": "table", - }), - RefId: "A", - }, - }, - } - - resp, err := endpoint.Query(nil, nil, query) - queryResult := resp.Results["A"] + c15_interval interval + ); + ` + _, err := sess.Exec(sql) So(err, ShouldBeNil) - column := queryResult.Tables[0].Rows[0] - So(column[0].(int64), ShouldEqual, 1) - So(column[1].(int64), ShouldEqual, 2) - So(column[2].(int64), ShouldEqual, 3) - So(column[3].(float64), ShouldEqual, 4.5) - So(column[4].(float64), ShouldEqual, 6.7) - // libpq doesnt properly convert decimal, numeric and char to go types but returns []uint8 instead - // So(column[5].(float64), ShouldEqual, 1.1) - // So(column[6].(float64), ShouldEqual, 1.2) - // So(column[7].(string), ShouldEqual, "char") - So(column[8].(string), ShouldEqual, "varchar10") - So(column[9].(string), ShouldEqual, "text") + sql = ` + INSERT INTO postgres_types VALUES( + 1,2,3, + 4.5,6.7,1.1,1.2, + 'char10','varchar10','text', - So(column[10].(time.Time), ShouldHaveSameTypeAs, time.Now()) - So(column[11].(time.Time), ShouldHaveSameTypeAs, time.Now()) - So(column[12].(time.Time), ShouldHaveSameTypeAs, time.Now()) - So(column[13].(time.Time), ShouldHaveSameTypeAs, time.Now()) - So(column[14].(time.Time), ShouldHaveSameTypeAs, time.Now()) + now(),now(),now(),now(),now(),'15m'::interval + ); + ` + _, err = sess.Exec(sql) + So(err, ShouldBeNil) - // libpq doesnt properly convert interval to go types but returns []uint8 instead - // So(column[15].(time.Time), ShouldHaveSameTypeAs, time.Now()) + Convey("When doing a table query should map Postgres column types to Go types", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT * FROM postgres_types", + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + column := queryResult.Tables[0].Rows[0] + So(column[0].(int64), ShouldEqual, 1) + So(column[1].(int64), ShouldEqual, 2) + So(column[2].(int64), ShouldEqual, 3) + + So(column[3].(float64), ShouldEqual, 4.5) + So(column[4].(float64), ShouldEqual, 6.7) + So(column[5].(float64), ShouldEqual, 1.1) + So(column[6].(float64), ShouldEqual, 1.2) + + So(column[7].(string), ShouldEqual, "char10 ") + So(column[8].(string), ShouldEqual, "varchar10") + So(column[9].(string), ShouldEqual, "text") + + So(column[10].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[11].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[12].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[13].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[14].(time.Time), ShouldHaveSameTypeAs, time.Now()) + + So(column[15].(string), ShouldEqual, "00:15:00") + }) + }) + + Convey("Given a table with metrics that lacks data for some series ", func() { + sql := ` + DROP TABLE IF EXISTS metric; + CREATE TABLE metric ( + time timestamp, + value integer + ) + ` + + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + type metric struct { + Time time.Time + Value int64 + } + + series := []*metric{} + firstRange := genTimeRangeByInterval(fromStart, 10*time.Minute, 10*time.Second) + secondRange := genTimeRangeByInterval(fromStart.Add(20*time.Minute), 10*time.Minute, 10*time.Second) + + for _, t := range firstRange { + series = append(series, &metric{ + Time: t, + Value: 15, + }) + } + + for _, t := range secondRange { + series = append(series, &metric{ + Time: t, + Value: 20, + }) + } + + for _, s := range series { + _, err = sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query using timeGroup", func() { + query := &tsdb.TsdbQuery{ + 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", + "format": "time_series", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + points := queryResult.Series[0].Points + So(len(points), ShouldEqual, 6) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 3; 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) + } + }) + + Convey("When doing a metric query using timeGroup with NULL fill enabled", func() { + query := &tsdb.TsdbQuery{ + 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", + "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(len(points), ShouldEqual, 7) + + dt := fromStart + + for i := 0; i < 3; i++ { + aValue := points[i][0].Float64 + aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) + So(aValue, ShouldEqual, 15) + So(aTime, ShouldEqual, dt) + dt = dt.Add(5 * time.Minute) + } + + So(points[3][0].Valid, ShouldBeFalse) + + // adjust for 5 minute gap + dt = dt.Add(5 * time.Minute) + for i := 4; i < 7; 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) + } + }) + + Convey("When doing a metric query using timeGroup with float 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", + "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[3][0].Float64, ShouldEqual, 1.5) + }) + }) + + Convey("Given a table with metrics having multiple values and measurements", func() { + type metric_values struct { + Time time.Time + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` + } + + if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(metric_values{}) + } + err := sess.CreateTable(metric_values{}) + So(err, ShouldBeNil) + + rand.Seed(time.Now().Unix()) + rnd := func(min, max int64) int64 { + return rand.Int63n(max-min) + min + } + + series := []*metric_values{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + series = append(series, &metric_values{ + Time: t, + Measurement: "Metric B", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + }) + } + + for _, s := range series { + _, err := sess.Insert(s) + So(err, ShouldBeNil) + } + + Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__timeEpoch(time), measurement || ' - value one' as metric, "valueOne" 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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one") + So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") + }) + + Convey("When doing a metric query grouping by time should return correct series", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__timeEpoch(time), "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, 2) + So(queryResult.Series[0].Name, ShouldEqual, "valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "valueTwo") + }) + }) + + Convey("Given a table with event data", func() { + type event struct { + TimeSec int64 + Description string + Tags string + } + + if exist, err := sess.IsTableExist(event{}); err != nil || exist { + So(err, ShouldBeNil) + sess.DropTable(event{}) + } + err := sess.CreateTable(event{}) + So(err, ShouldBeNil) + + events := []*event{} + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + events = append(events, &event{ + TimeSec: t.Unix(), + Description: "Someone deployed something", + Tags: "deploy", + }) + events = append(events, &event{ + TimeSec: t.Add(5 * time.Minute).Unix(), + Description: "New support ticket registered", + Tags: "ticket", + }) + } + + for _, e := range events { + _, err = sess.Insert(e) + So(err, ShouldBeNil) + } + + Convey("When doing an annotation query of deploy events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "time_sec" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='deploy' ORDER BY 1 ASC`, + "format": "table", + }), + RefId: "Deploys", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Deploys"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query of ticket events should return expected result", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "time_sec" as time, description as text, tags FROM event WHERE $__unixEpochFilter(time_sec) AND tags='ticket' ORDER BY 1 ASC`, + "format": "table", + }), + RefId: "Tickets", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Add(-20*time.Minute).Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(40*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["Tickets"] + So(err, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) + }) + + Convey("When doing an annotation query with a time column in datetime format", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + dtFormat := "2006-01-02 15:04:05.999999999" + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + CAST('%s' AS TIMESTAMP) as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Format(dtFormat)), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch second format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + cast(%d as bigint) as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + }) + + Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": fmt.Sprintf(`SELECT + %d as time, + 'message' as text, + 'tag1,tag2' as tags + `, dt.Unix()*1000), + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) + }) + + Convey("When doing an annotation query with a time column holding a bigint null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as bigint) as time, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) + + Convey("When doing an annotation query with a time column holding a timestamp null value should return nil", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT + cast(null as timestamp) as time, + 'message' as text, + 'tag1,tag2' as tags + `, + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(len(queryResult.Tables[0].Rows), ShouldEqual, 1) + columns := queryResult.Tables[0].Rows[0] + + //Should be in milliseconds + So(columns[0], ShouldBeNil) + }) }) }) } func InitPostgresTestDB(t *testing.T) *xorm.Engine { x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + x.DatabaseTZ = time.UTC + x.TZLocation = time.UTC // x.ShowSQL() @@ -119,7 +636,18 @@ func InitPostgresTestDB(t *testing.T) *xorm.Engine { t.Fatalf("Failed to init postgres db %v", err) } - sqlutil.CleanDB(x) - return x } + +func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time { + durationSec := int64(duration.Seconds()) + intervalSec := int64(interval.Seconds()) + timeRange := []time.Time{} + + for i := int64(0); i < durationSec; i += intervalSec { + timeRange = append(timeRange, from) + from = from.Add(time.Duration(int64(time.Second) * intervalSec)) + } + + return timeRange +} diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go index 12778b4e1ad..16370a4ea7f 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sql_engine.go @@ -3,6 +3,7 @@ package tsdb import ( "context" "sync" + "time" "github.com/go-xorm/core" "github.com/go-xorm/xorm" @@ -17,15 +18,15 @@ type SqlEngine interface { ctx context.Context, ds *models.DataSource, query *TsdbQuery, - transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult) error, - transformToTable func(query *Query, rows *core.Rows, result *QueryResult) error, + 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 timeRange to be able to -// generate queries that use from and to. +// 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(timeRange *TimeRange, sql string) (string, error) + Interpolate(query *Query, timeRange *TimeRange, sql string) (string, error) } type DefaultSqlEngine struct { @@ -77,8 +78,8 @@ func (e *DefaultSqlEngine) Query( ctx context.Context, dsInfo *models.DataSource, tsdbQuery *TsdbQuery, - transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult) error, - transformToTable func(query *Query, rows *core.Rows, result *QueryResult) error, + 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) { result := &Response{ Results: make(map[string]*QueryResult), @@ -97,7 +98,7 @@ func (e *DefaultSqlEngine) Query( queryResult := &QueryResult{Meta: simplejson.New(), RefId: query.RefId} result.Results[query.RefId] = queryResult - rawSql, err := e.MacroEngine.Interpolate(tsdbQuery.TimeRange, rawSql) + rawSql, err := e.MacroEngine.Interpolate(query, tsdbQuery.TimeRange, rawSql) if err != nil { queryResult.Error = err continue @@ -117,13 +118,13 @@ func (e *DefaultSqlEngine) Query( switch format { case "time_series": - err := transformToTimeSeries(query, rows, queryResult) + err := transformToTimeSeries(query, rows, queryResult, tsdbQuery) if err != nil { queryResult.Error = err continue } case "table": - err := transformToTable(query, rows, queryResult) + err := transformToTable(query, rows, queryResult, tsdbQuery) if err != nil { queryResult.Error = err continue @@ -133,3 +134,30 @@ func (e *DefaultSqlEngine) Query( return result, nil } + +// ConvertTimeColumnToEpochMs 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) { + if timeIndex >= 0 { + switch value := values[timeIndex].(type) { + case time.Time: + values[timeIndex] = EpochPrecisionToMs(float64(value.Unix())) + case *time.Time: + if value != nil { + values[timeIndex] = EpochPrecisionToMs(float64((*value).Unix())) + } + case int64: + values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + case *int64: + if value != nil { + values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + } + case float64: + values[timeIndex] = EpochPrecisionToMs(value) + case *float64: + if value != nil { + values[timeIndex] = EpochPrecisionToMs(*value) + } + } + } +} diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sql_engine_test.go new file mode 100644 index 00000000000..48aac2c4d45 --- /dev/null +++ b/pkg/tsdb/sql_engine_test.go @@ -0,0 +1,46 @@ +package tsdb + +import ( + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestSqlEngine(t *testing.T) { + Convey("SqlEngine", t, func() { + Convey("Given row values with time columns when converting them", func() { + dt := time.Date(2018, 3, 14, 21, 20, 6, 527e6, time.UTC) + fixtures := make([]interface{}, 8) + fixtures[0] = dt + fixtures[1] = dt.Unix() * 1000 + fixtures[2] = dt.Unix() + fixtures[3] = float64(dt.Unix() * 1000) + fixtures[4] = float64(dt.Unix()) + + var nilDt *time.Time + var nilInt64 *int64 + var nilFloat64 *float64 + fixtures[5] = nilDt + fixtures[6] = nilInt64 + fixtures[7] = nilFloat64 + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("Should convert sql time columns to epoch time in ms ", func() { + expected := float64(dt.Unix() * 1000) + So(fixtures[0].(float64), ShouldEqual, expected) + So(fixtures[1].(int64), ShouldEqual, expected) + So(fixtures[2].(int64), ShouldEqual, expected) + So(fixtures[3].(float64), ShouldEqual, expected) + So(fixtures[4].(float64), ShouldEqual, expected) + + So(fixtures[5], ShouldBeNil) + So(fixtures[6], ShouldBeNil) + So(fixtures[7], ShouldBeNil) + }) + }) + }) +} diff --git a/pkg/tsdb/time_range.go b/pkg/tsdb/time_range.go index fd797bf731a..fd0cb3f8e82 100644 --- a/pkg/tsdb/time_range.go +++ b/pkg/tsdb/time_range.go @@ -88,3 +88,13 @@ func (tr *TimeRange) ParseTo() (time.Time, error) { return time.Time{}, fmt.Errorf("cannot parse to value %s", tr.To) } + +// EpochPrecisionToMs converts epoch precision to millisecond, if needed. +// Only seconds to milliseconds supported right now +func EpochPrecisionToMs(value float64) float64 { + if int64(value)/1e10 == 0 { + return float64(value * 1e3) + } + + return float64(value) +} diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go index 067f7c756ba..d87b6f70fe6 100644 --- a/pkg/util/shortid_generator.go +++ b/pkg/util/shortid_generator.go @@ -1,14 +1,29 @@ package util import ( + "regexp" + "github.com/teris-io/shortid" ) +var allowedChars = shortid.DefaultABC + +var validUidPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString + func init() { - gen, _ := shortid.New(1, shortid.DefaultABC, 1) + gen, _ := shortid.New(1, allowedChars, 1) shortid.SetDefault(gen) } +// IsValidShortUid checks if short unique identifier contains valid characters +func IsValidShortUid(uid string) bool { + if !validUidPattern(uid) { + return false + } + + return true +} + // GenerateShortUid generates a short unique identifier. func GenerateShortUid() string { return shortid.MustGenerate() diff --git a/pkg/util/shortid_generator_test.go b/pkg/util/shortid_generator_test.go new file mode 100644 index 00000000000..359e054a0ca --- /dev/null +++ b/pkg/util/shortid_generator_test.go @@ -0,0 +1,11 @@ +package util + +import "testing" + +func TestAllowedCharMatchesUidPattern(t *testing.T) { + for _, c := range allowedChars { + if !IsValidShortUid(string(c)) { + t.Fatalf("charset for creating new shortids contains chars not present in uid pattern") + } + } +} diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index 6fb6e3b7d8f..9ecb9a177d7 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -147,8 +147,7 @@ export class AlertRuleItem extends React.Component {
- {rule.canEdit && {this.renderText(rule.name)}} - {!rule.canEdit && {this.renderText(rule.name)}} + {this.renderText(rule.name)}
{this.renderText(rule.stateText)} @@ -163,24 +162,12 @@ export class AlertRuleItem extends React.Component { className="btn btn-small btn-inverse alert-list__btn width-2" title="Pausing an alert rule prevents it from executing" onClick={this.toggleState} - disabled={!rule.canEdit} > - {rule.canEdit && ( - - - - )} - {!rule.canEdit && ( - - )} + + +
); diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap index 0914f050a0f..f408f6409be 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap +++ b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap @@ -82,7 +82,6 @@ exports[`AlertRuleList should render 1 rule 1`] = ` >
- {permissions.error ? ( -
- - - {permissions.error} - -
- ) : null} ); } diff --git a/public/app/core/components/code_editor/code_editor.ts b/public/app/core/components/code_editor/code_editor.ts index 2a0fe103bcf..886ae2a6407 100644 --- a/public/app/core/components/code_editor/code_editor.ts +++ b/public/app/core/components/code_editor/code_editor.ts @@ -21,6 +21,8 @@ * data-tab-size - Tab size, default is 2. * data-behaviours-enabled - Specifies whether to use behaviors or not. "Behaviors" in this case is the auto-pairing of * special characters, like quotation marks, parenthesis, or brackets. + * data-snippets-enabled - Specifies whether to use snippets or not. "Snippets" are small pieces of code that can be + * inserted via the completion box. * * Keybindings: * Ctrl-Enter (Command-Enter): run onChange() function @@ -36,6 +38,8 @@ import 'brace/mode/text'; import 'brace/snippets/text'; import 'brace/mode/sql'; import 'brace/snippets/sql'; +import 'brace/mode/sqlserver'; +import 'brace/snippets/sqlserver'; import 'brace/mode/markdown'; import 'brace/snippets/markdown'; import 'brace/mode/json'; @@ -47,6 +51,7 @@ const DEFAULT_MODE = 'text'; const DEFAULT_MAX_LINES = 10; const DEFAULT_TAB_SIZE = 2; const DEFAULT_BEHAVIOURS = true; +const DEFAULT_SNIPPETS = true; let editorTemplate = `
`; @@ -57,6 +62,7 @@ function link(scope, elem, attrs) { let showGutter = attrs.showGutter !== undefined; let tabSize = attrs.tabSize || DEFAULT_TAB_SIZE; let behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS; + let snippetsEnabled = attrs.snippetsEnabled ? attrs.snippetsEnabled === 'true' : DEFAULT_SNIPPETS; // Initialize editor let aceElem = elem.get(0); @@ -141,7 +147,7 @@ function link(scope, elem, attrs) { codeEditor.setOptions({ enableBasicAutocompletion: true, enableLiveAutocompletion: true, - enableSnippets: true, + enableSnippets: snippetsEnabled, }); if (scope.getCompleter()) { diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 70a1bda3e8b..798a40cb1bf 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -87,6 +87,11 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop elem.toggleClass('playlist-active', newValue === true); }); + // check if we are in server side render + if (document.cookie.indexOf('renderKey') !== -1) { + body.addClass('body--phantomjs'); + } + // tooltip removal fix // manage page classes var pageClass; diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index c2f6f213dd3..a1d3c34ae5b 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -31,12 +31,9 @@ export class HelpCtrl { { keys: ['e'], description: 'Toggle panel edit view' }, { keys: ['v'], description: 'Toggle panel fullscreen view' }, { keys: ['p', 's'], description: 'Open Panel Share Modal' }, + { keys: ['p', 'd'], description: 'Duplicate Panel' }, { keys: ['p', 'r'], description: 'Remove Panel' }, ], - 'Focused Row': [ - { keys: ['r', 'c'], description: 'Collapse Row' }, - { keys: ['r', 'r'], description: 'Remove Row' }, - ], 'Time Range': [ { keys: ['t', 'z'], description: 'Zoom out time range' }, { diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 4a8ab32ab7d..545119a80d7 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -78,8 +78,8 @@ export class ManageDashboardsCtrl { return; } - return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => { - this.canSave = dash.meta.canSave; + return this.backendSrv.getFolderByUid(this.folderUid).then(folder => { + this.canSave = folder.canSave; }); }); } @@ -173,48 +173,13 @@ export class ManageDashboardsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - const foldersAndDashboards = data.folders.concat(data.dashboards); - this.deleteFoldersAndDashboards(foldersAndDashboards); + this.deleteFoldersAndDashboards(data.folders, data.dashboards); }, }); } - private deleteFoldersAndDashboards(uids) { - this.backendSrv.deleteDashboards(uids).then(result => { - const folders = _.filter(result, dash => dash.meta.isFolder); - const folderCount = folders.length; - const dashboards = _.filter(result, dash => !dash.meta.isFolder); - const dashCount = dashboards.length; - - if (result.length > 0) { - let header; - let msg; - - if (folderCount > 0 && dashCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `; - msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } else if (folderCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} Deleted`; - - if (folderCount === 1) { - msg = `${folders[0].dashboard.title} has been deleted`; - } else { - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`; - } - } else if (dashCount > 0) { - header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - - if (dashCount === 1) { - msg = `${dashboards[0].dashboard.title} has been deleted`; - } else { - msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } - } - - appEvents.emit('alert-success', [header, msg]); - } - + private deleteFoldersAndDashboards(folderUids, dashboardUids) { + this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => { this.refreshList(); }); } diff --git a/public/app/core/components/org_switcher.ts b/public/app/core/components/org_switcher.ts index 1816e11af49..f7b53fa3c81 100644 --- a/public/app/core/components/org_switcher.ts +++ b/public/app/core/components/org_switcher.ts @@ -15,8 +15,7 @@ const template = ` - - diff --git a/public/app/features/org/select_org_ctrl.ts b/public/app/features/org/select_org_ctrl.ts index 1001b4b41e5..199d1f8ac94 100644 --- a/public/app/features/org/select_org_ctrl.ts +++ b/public/app/features/org/select_org_ctrl.ts @@ -6,6 +6,14 @@ export class SelectOrgCtrl { constructor($scope, backendSrv, contextSrv) { contextSrv.sidemenu = false; + $scope.navModel = { + main: { + icon: 'gicon gicon-branding', + subTitle: 'Preferences', + text: 'Select active organization', + }, + }; + $scope.init = function() { $scope.getUserOrgs(); }; diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 373211611d8..177f0c7bf00 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -78,8 +78,11 @@ class MetricsPanelCtrl extends PanelCtrl { data = data.data; } - this.events.emit('data-snapshot-load', data); - return; + // Defer panel rendering till the next digest cycle. + // For some reason snapshot panels don't init at this time, so this helps to avoid rendering issues. + return this.$timeout(() => { + this.events.emit('data-snapshot-load', data); + }); } // // ignore if we have data stream @@ -222,6 +225,7 @@ class MetricsPanelCtrl extends PanelCtrl { var metricsQuery = { timezone: this.dashboard.getTimezone(), panelId: this.panel.id, + dashboardId: this.dashboard.id, range: this.range, rangeRaw: this.range.raw, interval: this.interval, diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index d8757f49be6..f54877c2c37 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -190,6 +190,7 @@ export class PanelCtrl { text: 'Duplicate', click: 'ctrl.duplicate()', role: 'Editor', + shortcut: 'p d', }); menu.push({ @@ -241,31 +242,10 @@ export class PanelCtrl { }); } - removePanel(ask: boolean) { - // confirm deletion - if (ask !== false) { - var text2, confirmText; - - if (this.panel.alert) { - text2 = 'Panel includes an alert rule, removing panel will also remove alert rule'; - confirmText = 'YES'; - } - - appEvents.emit('confirm-modal', { - title: 'Remove Panel', - text: 'Are you sure you want to remove this panel?', - text2: text2, - icon: 'fa-trash', - confirmText: confirmText, - yesText: 'Remove', - onConfirm: () => { - this.removePanel(false); - }, - }); - return; - } - - this.dashboard.removePanel(this.panel); + removePanel() { + this.publishAppEvent('panel-remove', { + panelId: this.panel.id, + }); } editPanelJson() { diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 2c7698db08e..242d2e7da3e 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -18,7 +18,7 @@ export class SoloPanelCtrl { // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { - backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => { + backendSrv.getDashboardBySlug($routeParams.slug).then(res => { if (res) { const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); $location.path(url).replace(); diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index c86efc4f695..6998321dd75 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -8,6 +8,7 @@ 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 textPanel from 'app/plugins/panel/text/module'; import * as graphPanel from 'app/plugins/panel/graph/module'; @@ -32,6 +33,7 @@ const builtInPlugins = { '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, diff --git a/public/app/features/plugins/import_list/import_list.html b/public/app/features/plugins/import_list/import_list.html index fec7ba190ec..523005ae87b 100644 --- a/public/app/features/plugins/import_list/import_list.html +++ b/public/app/features/plugins/import_list/import_list.html @@ -9,9 +9,7 @@ {{dash.title}} - - {{dash.title}} - + {{dash.title}}