diff --git a/.bra.toml b/.bra.toml index 36421a3c5a6..dcf316466d6 100644 --- a/.bra.toml +++ b/.bra.toml @@ -1,6 +1,6 @@ [run] init_cmds = [ - ["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"], + ["go", "run", "build.go", "-dev", "build-server"], ["./bin/grafana-server", "cfg:app_mode=development"] ] watch_all = true @@ -12,6 +12,6 @@ watch_dirs = [ watch_exts = [".go", ".ini", ".toml"] build_delay = 1500 cmds = [ - ["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"], + ["go", "run", "build.go", "-dev", "build-server"], ["./bin/grafana-server", "cfg:app_mode=development"] ] diff --git a/.circleci/config.yml b/.circleci/config.yml index cfa8b762e49..b6514c4cb47 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,38 @@ version: 2 jobs: + codespell: + docker: + - image: circleci/python + steps: + - checkout + - run: + name: install codespell + command: 'sudo pip install codespell' + - run: + # Important: all words have to be in lowercase, and separated by "\n". + name: exclude known exceptions + command: 'echo -e "unknwon" > words_to_ignore.txt' + - run: + name: check documentation spelling errors + command: 'codespell -I ./words_to_ignore.txt docs/' + + gometalinter: + docker: + - image: circleci/golang:1.10 + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - run: + name: install gometalinter tool + command: 'go get -u github.com/alecthomas/gometalinter' + - run: + name: install linters + command: 'gometalinter --install' + - run: + name: run some linters + command: 'gometalinter --vendor --deadline 6m --disable-all --enable=structcheck --enable=unconvert --enable=varcheck ./pkg/...' + test-frontend: docker: - image: circleci/node:6.11.4 @@ -85,7 +117,7 @@ jobs: - image: circleci/python:2.7-stretch steps: - attach_workspace: - at: dist + at: . - run: name: install awscli command: 'sudo pip install awscli' @@ -103,6 +135,10 @@ workflows: version: 2 test-and-build: jobs: + - codespell: + filters: + tags: + only: /.*/ - build: filters: tags: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f763a15c82..63ec1965098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 5.1.0 (unreleased) +# 5.1.0-beta1 (2018-04-20) * **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) @@ -15,8 +15,13 @@ * **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17) * **Table**: Table plugin value mappings [#7119](https://github.com/grafana/grafana/issues/7119), thx [infernix](https://github.com/infernix) * **IE11**: IE 11 compatibility [#11165](https://github.com/grafana/grafana/issues/11165) +* **Scrolling**: Better scrolling experience [#11053](https://github.com/grafana/grafana/issues/11053), [#11252](https://github.com/grafana/grafana/issues/11252), [#10836](https://github.com/grafana/grafana/issues/10836), [#11185](https://github.com/grafana/grafana/issues/11185), [#11168](https://github.com/grafana/grafana/issues/11168) +* **Docker**: Improved docker image (breaking changes regarding file ownership) [grafana-docker #141](https://github.com/grafana/grafana-docker/issues/141), thx [@Spindel](https://github.com/Spindel), [@ChristianKniep](https://github.com/ChristianKniep), [@brancz](https://github.com/brancz) and [@jangaraj](https://github.com/jangaraj) +* **Folders**: A folder admin cannot add user/team permissions for folder/its dashboards [#11173](https://github.com/grafana/grafana/issues/11173) +* **Provisioning**: Improved workflow for provisioned dashboards [#10883](https://github.com/grafana/grafana/issues/10883) ### 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) @@ -28,7 +33,35 @@ * **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) * **Prometheus**: Escape backslash in labels correctly. [#10555](https://github.com/grafana/grafana/issues/10555), thx [@roidelapluie](https://github.com/roidelapluie) -* **Variables** Case-insensitive sorting for template values [#11128](https://github.com/grafana/grafana/issues/11128) thx [@cross](https://github.com/cross) +* **Variables**: Case-insensitive sorting for template values [#11128](https://github.com/grafana/grafana/issues/11128) thx [@cross](https://github.com/cross) +* **Annotations (native)**: Change default limit from 10 to 100 when querying api [#11569](https://github.com/grafana/grafana/issues/11569), thx [@flopp999](https://github.com/flopp999) +* **MySQL/Postgres/MSSQL**: PostgreSQL datasource generates invalid query with dates before 1970 [#11530](https://github.com/grafana/grafana/issues/11530) thx [@ryantxu](https://github.com/ryantxu) +* **Kiosk**: Adds url parameter for starting a dashboard in inactive mode [#11228](https://github.com/grafana/grafana/issues/11228), thx [@towolf](https://github.com/towolf) +* **Dashboard**: Enable closing timepicker using escape key [#11332](https://github.com/grafana/grafana/issues/11332) +* **Datasources**: Rename direct access mode in the data source settings [#11391](https://github.com/grafana/grafana/issues/11391) +* **Search**: Display dashboards in folder indented [#11073](https://github.com/grafana/grafana/issues/11073) +* **Units**: Use B/s instead Bps for Bytes per second [#9342](https://github.com/grafana/grafana/pull/9342), thx [@mayli](https://github.com/mayli) +* **Units**: Radiation units [#11001](https://github.com/grafana/grafana/issues/11001), thx [@victorclaessen](https://github.com/victorclaessen) +* **Units**: Timeticks unit [#11183](https://github.com/grafana/grafana/pull/11183), thx [@jtyr](https://github.com/jtyr) +* **Units**: Concentration units and "Normal cubic metre" [#11211](https://github.com/grafana/grafana/issues/11211), thx [@flopp999](https://github.com/flopp999) +* **Units**: New currency - Czech koruna [#11384](https://github.com/grafana/grafana/pull/11384), thx [@Rohlik](https://github.com/Rohlik) +* **Avatar**: Fix DISABLE_GRAVATAR option [#11095](https://github.com/grafana/grafana/issues/11095) +* **Heatmap**: Disable log scale when using time time series buckets [#10792](https://github.com/grafana/grafana/issues/10792) +* **Provisioning**: Remove `id` from json when provisioning dashboards, [#11138](https://github.com/grafana/grafana/issues/11138) +* **Prometheus**: tooltip for legend format not showing properly [#11516](https://github.com/grafana/grafana/issues/11516), thx [@svenklemm](https://github.com/svenklemm) +* **Playlist**: Empty playlists cannot be deleted [#11133](https://github.com/grafana/grafana/issues/11133), thx [@kichristensen](https://github.com/kichristensen) +* **Switch Orgs**: Alphabetic order in Switch Organization modal [#11556](https://github.com/grafana/grafana/issues/11556) +* **Postgres**: improve `$__timeFilter` macro [#11578](https://github.com/grafana/grafana/issues/11578), thx [@svenklemm](https://github.com/svenklemm) +* **Permission list**: Improved ux [#10747](https://github.com/grafana/grafana/issues/10747) +* **Dashboard**: Sizing and positioning of settings menu icons [#11572](https://github.com/grafana/grafana/pull/11572) +* **Dashboard**: Add search filter/tabs to new panel control [#10427](https://github.com/grafana/grafana/issues/10427) +* **Folders**: User with org viewer role should not be able to save/move dashboards in/to general folder [#11553](https://github.com/grafana/grafana/issues/11553) +* **Influxdb**: Dont assume the first column in table response is time. [#11476](https://github.com/grafana/grafana/issues/11476), thx [@hahnjo](https://github.com/hahnjo) + +### Tech +* Backend code simplification [#11613](https://github.com/grafana/grafana/pull/11613), thx [@knweiss](https://github.com/knweiss) +* Add codespell to CI [#11602](https://github.com/grafana/grafana/pull/11602), thx [@mjtrangoni](https://github.com/mjtrangoni) +* Migrated JavaScript files to TypeScript # 5.0.4 (2018-03-28) @@ -56,7 +89,7 @@ * **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) +* **Dashboard**: Fixes a bug with resizable 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) @@ -120,7 +153,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha ### New Major Features - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. -- **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). +- **Dashboard grid**: Panels are now laid out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). - **Templating**: Vertical repeat direction for panel repeats. - **UX**: Major update to page header and navigation - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) @@ -154,7 +187,7 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: * **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671) * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) -* **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +* **Table**: Support multiple table formatted queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) * **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) ## Minor @@ -176,7 +209,7 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) -* **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +* **Pagerduty**: Pagerduty don't auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) * **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903) ## Tech @@ -254,7 +287,7 @@ The following properties have been deprecated and will be removed in a future re * **Annotations**: Add support for creating annotations from graph panel [#8197](https://github.com/grafana/grafana/pull/8197) * **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) * **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) -* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +* **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) * **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) * **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) * **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) @@ -291,7 +324,7 @@ The following properties have been deprecated and will be removed in a future re * **Graphite**: Fix for Grafana internal metrics to Graphite sending NaN values [#9279](https://github.com/grafana/grafana/issues/9279) * **HTTP API**: Fix for HEAD method requests [#9307](https://github.com/grafana/grafana/issues/9307) * **Templating**: Fix for duplicate template variable queries when refresh is set to time range change [#9185](https://github.com/grafana/grafana/issues/9185) -* **Metrics**: dont write NaN values to graphite [#9279](https://github.com/grafana/grafana/issues/9279) +* **Metrics**: don't write NaN values to graphite [#9279](https://github.com/grafana/grafana/issues/9279) # 4.5.1 (2017-09-15) @@ -328,12 +361,12 @@ The following properties have been deprecated and will be removed in a future re ### Breaking change * **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. -* **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formated data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. +* **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formatted data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. ## Changes * **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) -* **InfluxDB**: Added paranthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) +* **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) ## Bug Fixes @@ -345,7 +378,7 @@ The following properties have been deprecated and will be removed in a future re ## Bug Fixes -* **Search**: Fix for issue that casued search view to hide when you clicked starred or tags filters, fixes [#8981](https://github.com/grafana/grafana/issues/8981) +* **Search**: Fix for issue that caused search view to hide when you clicked starred or tags filters, fixes [#8981](https://github.com/grafana/grafana/issues/8981) * **Modals**: ESC key now closes modal again, fixes [#8981](https://github.com/grafana/grafana/issues/8988), thx [@j-white](https://github.com/j-white) # 4.4.2 (2017-08-01) @@ -684,12 +717,12 @@ due to too many connections/file handles on the data source backend. This proble ### Enhancements * **Login**: Adds option to disable username/password logins, closes [#4674](https://github.com/grafana/grafana/issues/4674) * **SingleStat**: Add seriename as option in singlestat panel, closes [#4740](https://github.com/grafana/grafana/issues/4740) -* **Localization**: Week start day now dependant on browser locale setting, closes [#3003](https://github.com/grafana/grafana/issues/3003) +* **Localization**: Week start day now dependent on browser locale setting, closes [#3003](https://github.com/grafana/grafana/issues/3003) * **Templating**: Update panel repeats for variables that change on time refresh, closes [#5021](https://github.com/grafana/grafana/issues/5021) * **Templating**: Add support for numeric and alphabetical sorting of variable values, closes [#2839](https://github.com/grafana/grafana/issues/2839) * **Elasticsearch**: Support to set Precision Threshold for Unique Count metric, closes [#4689](https://github.com/grafana/grafana/issues/4689) * **Navigation**: Add search to org swithcer, closes [#2609](https://github.com/grafana/grafana/issues/2609) -* **Database**: Allow database config using one propertie, closes [#5456](https://github.com/grafana/grafana/pull/5456) +* **Database**: Allow database config using one property, closes [#5456](https://github.com/grafana/grafana/pull/5456) * **Graphite**: Add support for groupByNodes, closes [#5613](https://github.com/grafana/grafana/pull/5613) * **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827) * **OpenTSDB**: Add support for explicitTags for OpenTSDB>=2.3, closes [#6360](https://github.com/grafana/grafana/pull/6361) @@ -756,7 +789,7 @@ due to too many connections/file handles on the data source backend. This proble * **Datasource**: Pending data source requests are cancelled before new ones are issues (Graphite & Prometheus), closes [#5321](https://github.com/grafana/grafana/issues/5321) ### Breaking changes -* **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log ouput. +* **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log output. * **Graphite** : The Graph panel no longer have a Graphite PNG option. closes [#5367](https://github.com/grafana/grafana/issues/5367) ### Bug fixes @@ -774,7 +807,7 @@ due to too many connections/file handles on the data source backend. This proble * **Annotations**: Annotations can now use a template variable as data source, closes [#5054](https://github.com/grafana/grafana/issues/5054) * **Time picker**: Fixed issue timepicker and UTC when reading time from URL, fixes [#5078](https://github.com/grafana/grafana/issues/5078) * **CloudWatch**: Support for Multiple Account by AssumeRole, closes [#3522](https://github.com/grafana/grafana/issues/3522) -* **Singlestat**: Fixed alignment and minium height issue, fixes [#5113](https://github.com/grafana/grafana/issues/5113), fixes [#4679](https://github.com/grafana/grafana/issues/4679) +* **Singlestat**: Fixed alignment and minimum height issue, fixes [#5113](https://github.com/grafana/grafana/issues/5113), fixes [#4679](https://github.com/grafana/grafana/issues/4679) * **Share modal**: Fixed link when using grafana under dashboard sub url, fixes [#5109](https://github.com/grafana/grafana/issues/5109) * **Prometheus**: Fixed bug in query editor that caused it not to load when reloading page, fixes [#5107](https://github.com/grafana/grafana/issues/5107) * **Elasticsearch**: Fixed bug when template variable query returns numeric values, fixes [#5097](https://github.com/grafana/grafana/issues/5097), fixes [#5088](https://github.com/grafana/grafana/issues/5088) @@ -791,7 +824,7 @@ due to too many connections/file handles on the data source backend. This proble * **Graph**: Fixed broken PNG rendering in graph panel, fixes [#5025](https://github.com/grafana/grafana/issues/5025) * **Graph**: Fixed broken xaxis on graph panel, fixes [#5024](https://github.com/grafana/grafana/issues/5024) -* **Influxdb**: Fixes crash when hiding middle serie, fixes [#5005](https://github.com/grafana/grafana/issues/5005) +* **Influxdb**: Fixes crash when hiding middle series, fixes [#5005](https://github.com/grafana/grafana/issues/5005) # 3.0.1 Stable (2016-05-11) @@ -803,7 +836,7 @@ due to too many connections/file handles on the data source backend. This proble ### Bug fixes * **Dashboard title**: Fixed max dashboard title width (media query) for large screens, fixes [#4859](https://github.com/grafana/grafana/issues/4859) * **Annotations**: Fixed issue with entering annotation edit view, fixes [#4857](https://github.com/grafana/grafana/issues/4857) -* **Remove query**: Fixed issue with removing query for data sources without collapsable query editors, fixes [#4856](https://github.com/grafana/grafana/issues/4856) +* **Remove query**: Fixed issue with removing query for data sources without collapsible query editors, fixes [#4856](https://github.com/grafana/grafana/issues/4856) * **Graphite PNG**: Fixed issue graphite png rendering option, fixes [#4864](https://github.com/grafana/grafana/issues/4864) * **InfluxDB**: Fixed issue missing plus group by iconn, fixes [#4862](https://github.com/grafana/grafana/issues/4862) * **Graph**: Fixes missing line mode for thresholds, fixes [#4902](https://github.com/grafana/grafana/pull/4902) @@ -819,11 +852,11 @@ due to too many connections/file handles on the data source backend. This proble ### Bug fixes * **InfluxDB 0.12**: Fixed issue templating and `show tag values` query only returning tags for first measurement, fixes [#4726](https://github.com/grafana/grafana/issues/4726) -* **Templating**: Fixed issue with regex formating when matching multiple values, fixes [#4755](https://github.com/grafana/grafana/issues/4755) +* **Templating**: Fixed issue with regex formatting when matching multiple values, fixes [#4755](https://github.com/grafana/grafana/issues/4755) * **Templating**: Fixed issue with custom all value and escaping, fixes [#4736](https://github.com/grafana/grafana/issues/4736) * **Dashlist**: Fixed issue dashboard list panel and caching tags, fixes [#4768](https://github.com/grafana/grafana/issues/4768) * **Graph**: Fixed issue with unneeded scrollbar in legend for Firefox, fixes [#4760](https://github.com/grafana/grafana/issues/4760) -* **Table panel**: Fixed issue table panel formating string array properties, fixes [#4791](https://github.com/grafana/grafana/issues/4791) +* **Table panel**: Fixed issue table panel formatting string array properties, fixes [#4791](https://github.com/grafana/grafana/issues/4791) * **grafana-cli**: Improve error message when failing to install plugins due to corrupt response, fixes [#4651](https://github.com/grafana/grafana/issues/4651) * **Singlestat**: Fixes prefix an postfix for gauges, fixes [#4812](https://github.com/grafana/grafana/issues/4812) * **Singlestat**: Fixes auto-refresh on change for some options, fixes [#4809](https://github.com/grafana/grafana/issues/4809) @@ -915,7 +948,7 @@ slack channel (link to slack channel in readme). ### Bug fixes * **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794) * **InfluxDB**: Fix for InfluxDB and table panel when using Format As Table and having group by time, fixes [#3928](https://github.com/grafana/grafana/issues/3928) -* **Panel Time shift**: Fix for panel time range and using dashboard times liek `Today` and `This Week`, fixes [#3941](https://github.com/grafana/grafana/issues/3941) +* **Panel Time shift**: Fix for panel time range and using dashboard times like `Today` and `This Week`, fixes [#3941](https://github.com/grafana/grafana/issues/3941) * **Row repeat**: Repeated rows will now appear next to each other and not by the bottom of the dashboard, fixes [#3942](https://github.com/grafana/grafana/issues/3942) * **Png renderer**: Fix for phantomjs path on windows, fixes [#3657](https://github.com/grafana/grafana/issues/3657) @@ -939,7 +972,7 @@ slack channel (link to slack channel in readme). ### Bug Fixes * **metric editors**: Fix for clicking typeahead auto dropdown option, fixes [#3428](https://github.com/grafana/grafana/issues/3428) * **influxdb**: Fixed issue showing Group By label only on first query, fixes [#3453](https://github.com/grafana/grafana/issues/3453) -* **logging**: Add more verbose info logging for http reqeusts, closes [#3405](https://github.com/grafana/grafana/pull/3405) +* **logging**: Add more verbose info logging for http requests, closes [#3405](https://github.com/grafana/grafana/pull/3405) # 2.6.0-Beta1 (2015-12-04) @@ -966,7 +999,7 @@ slack channel (link to slack channel in readme). **New Feature: Mix data sources** - A built in data source is now available named `-- Mixed --`, When picked in the metrics tab, -it allows you to add queries of differnet data source types & instances to the same graph/panel! +it allows you to add queries of different data source types & instances to the same graph/panel! [Issue #436](https://github.com/grafana/grafana/issues/436) **New Feature: Elasticsearch Metrics Query Editor and Viz Support** @@ -1005,7 +1038,7 @@ it allows you to add queries of differnet data source types & instances to the s - [Issue #2564](https://github.com/grafana/grafana/issues/2564). Templating: Another atempt at fixing #2534 (Init multi value template var used in repeat panel from url) - [Issue #2620](https://github.com/grafana/grafana/issues/2620). Graph: multi series tooltip did no highlight correct point when stacking was enabled and series were of different resolution - [Issue #2636](https://github.com/grafana/grafana/issues/2636). InfluxDB: Do no show template vars in dropdown for tag keys and group by keys -- [Issue #2604](https://github.com/grafana/grafana/issues/2604). InfluxDB: More alias options, can now use `$[0-9]` syntax to reference part of a measurement name (seperated by dots) +- [Issue #2604](https://github.com/grafana/grafana/issues/2604). InfluxDB: More alias options, can now use `$[0-9]` syntax to reference part of a measurement name (separated by dots) **Breaking Changes** - Notice to makers/users of custom data sources, there is a minor breaking change in 2.2 that @@ -1087,7 +1120,7 @@ Grunt & Watch tasks: - [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior - [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request nameing - Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`) -- Search HTTP API response has changed (simplified), tags list moved to seperate HTTP resource URI +- Search HTTP API response has changed (simplified), tags list moved to separate HTTP resource URI - Datasource HTTP api breaking change, ADD datasource is now POST /api/datasources/, update is now PUT /api/datasources/:id **Fixes** @@ -1104,7 +1137,7 @@ Grunt & Watch tasks: # 2.0.2 (2015-04-22) **Fixes** -- [Issue #1832](https://github.com/grafana/grafana/issues/1832). Graph Panel + Legend Table mode: Many series casued zero height graph, now legend will never reduce the height of the graph below 50% of row height. +- [Issue #1832](https://github.com/grafana/grafana/issues/1832). Graph Panel + Legend Table mode: Many series caused zero height graph, now legend will never reduce the height of the graph below 50% of row height. - [Issue #1846](https://github.com/grafana/grafana/issues/1846). Snapshots: Fixed issue with snapshoting dashboards with an interval template variable - [Issue #1848](https://github.com/grafana/grafana/issues/1848). Panel timeshift: You can now use panel timeshift without a relative time override @@ -1146,7 +1179,7 @@ Grunt & Watch tasks: **Fixes** - [Issue #1649](https://github.com/grafana/grafana/issues/1649). HTTP API: grafana /render calls nows with api keys -- [Issue #1667](https://github.com/grafana/grafana/issues/1667). Datasource proxy & session timeout fix (casued 401 Unauthorized error after a while) +- [Issue #1667](https://github.com/grafana/grafana/issues/1667). Datasource proxy & session timeout fix (caused 401 Unauthorized error after a while) - [Issue #1707](https://github.com/grafana/grafana/issues/1707). Unsaved changes: Do not show for snapshots, scripted and file based dashboards - [Issue #1703](https://github.com/grafana/grafana/issues/1703). Unsaved changes: Do not show for users with role `Viewer` - [Issue #1675](https://github.com/grafana/grafana/issues/1675). Data source proxy: Fixed issue with Gzip enabled and data source proxy @@ -1159,14 +1192,14 @@ Grunt & Watch tasks: **Important Note** -Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated backend server. Please read the [Documentation](http://docs.grafana.org) for more detailed about this SIGNIFCANT change to Grafana +Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated backend server. Please read the [Documentation](http://docs.grafana.org) for more detailed about this SIGNIFICANT change to Grafana **New features** - [Issue #1623](https://github.com/grafana/grafana/issues/1623). Share Dashboard: Dashboard snapshot sharing (dash and data snapshot), save to local or save to public snapshot dashboard snapshots.raintank.io site - [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embedd a single graph on another web site -- [Issue #718](https://github.com/grafana/grafana/issues/718). Dashboard: When saving a dashboard and another user has made changes inbetween the user is promted with a warning if he really wants to overwrite the other's changes +- [Issue #718](https://github.com/grafana/grafana/issues/718). Dashboard: When saving a dashboard and another user has made changes in between the user is promted with a warning if he really wants to overwrite the other's changes - [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views -- [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change ``now`` to be for example ``now-1m``, usefull when you want to ignore last minute because it contains incomplete data +- [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change ``now`` to be for example ``now-1m``, useful when you want to ignore last minute because it contains incomplete data - [Issue #171](https://github.com/grafana/grafana/issues/171). Panel: Different time periods, panels can override dashboard relative time and/or add a time shift - [Issue #1488](https://github.com/grafana/grafana/issues/1488). Dashboard: Clone dashboard / Save as - [Issue #1458](https://github.com/grafana/grafana/issues/1458). User: persisted user option for dark or light theme (no longer an option on a dashboard) @@ -1197,7 +1230,7 @@ Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated **OpenTSDB breaking change** - [Issue #1438](https://github.com/grafana/grafana/issues/1438). OpenTSDB: Automatic downsample interval passed to OpenTSDB (depends on timespan and graph width) -- NOTICE, Downsampling is now enabled by default, so if you have not picked a downsample aggregator in your metric query do so or your graphs will be missleading +- NOTICE, Downsampling is now enabled by default, so if you have not picked a downsample aggregator in your metric query do so or your graphs will be misleading - This will make Grafana a lot quicker for OpenTSDB users when viewing large time spans without having to change the downsample interval manually. **Tech** @@ -1228,7 +1261,7 @@ Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated - [Issue #1114](https://github.com/grafana/grafana/issues/1114). Graphite: Lexer fix, allow equal sign (=) in metric paths - [Issue #1136](https://github.com/grafana/grafana/issues/1136). Graph: Fix to legend value Max and negative values - [Issue #1150](https://github.com/grafana/grafana/issues/1150). SinglestatPanel: Fixed absolute drilldown link issue -- [Issue #1123](https://github.com/grafana/grafana/issues/1123). Firefox: Workaround for Firefox bug, casued input text fields to not be selectable and not have placeable cursor +- [Issue #1123](https://github.com/grafana/grafana/issues/1123). Firefox: Workaround for Firefox bug, caused input text fields to not be selectable and not have placeable cursor - [Issue #1108](https://github.com/grafana/grafana/issues/1108). Graph: Fix for tooltip series order when series draw order was changed with zindex property # 1.9.0-rc1 (2014-11-17) @@ -1305,7 +1338,7 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele - [Issue #234](https://github.com/grafana/grafana/issues/234). Templating: Interval variable type for time intervals summarize/group by parameter, included "auto" option, and auto step counts option. - [Issue #262](https://github.com/grafana/grafana/issues/262). Templating: Ability to use template variables for function parameters via custom variable type, can be used as parameter for movingAverage or scaleToSeconds for example - [Issue #312](https://github.com/grafana/grafana/issues/312). Templating: Can now use template variables in panel titles -- [Issue #613](https://github.com/grafana/grafana/issues/613). Templating: Full support for InfluxDB, filter by part of series names, extract series substrings, nested queries, multipe where clauses! +- [Issue #613](https://github.com/grafana/grafana/issues/613). Templating: Full support for InfluxDB, filter by part of series names, extract series substrings, nested queries, multiple where clauses! - Template variables can be initialized from url, with var-my_varname=value, breaking change, before it was just my_varname. - Templating and url state sync has some issues that are not solved for this release, see [Issue #772](https://github.com/grafana/grafana/issues/772) for more details. @@ -1394,7 +1427,7 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele - [Issue #136](https://github.com/grafana/grafana/issues/136). Graph: New legend display option "Align as table" - [Issue #556](https://github.com/grafana/grafana/issues/556). Graph: New legend display option "Right side", will show legend to the right of the graph - [Issue #604](https://github.com/grafana/grafana/issues/604). Graph: New axis format, 'bps' (SI unit in steps of 1000) useful for network gear metics -- [Issue #626](https://github.com/grafana/grafana/issues/626). Graph: Downscale y axis to more precise unit, value of 0.1 for seconds format will be formated as 100 ms. Thanks @kamaradclimber +- [Issue #626](https://github.com/grafana/grafana/issues/626). Graph: Downscale y axis to more precise unit, value of 0.1 for seconds format will be formatted as 100 ms. Thanks @kamaradclimber - [Issue #618](https://github.com/grafana/grafana/issues/618). OpenTSDB: Series alias option to override metric name returned from opentsdb. Thanks @heldr **Documentation** @@ -1424,13 +1457,13 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele - [Issue #522](https://github.com/grafana/grafana/issues/522). Series names and column name typeahead cache fix - [Issue #504](https://github.com/grafana/grafana/issues/504). Fixed influxdb issue with raw query that caused wrong value column detection - [Issue #526](https://github.com/grafana/grafana/issues/526). Default property that marks which datasource is default in config.js is now optional -- [Issue #342](https://github.com/grafana/grafana/issues/342). Auto-refresh caused 2 refreshes (and hence mulitple queries) each time (at least in firefox) +- [Issue #342](https://github.com/grafana/grafana/issues/342). Auto-refresh caused 2 refreshes (and hence multiple queries) each time (at least in firefox) # 1.6.0 (2014-06-16) #### New features or improvements - [Issue #427](https://github.com/grafana/grafana/issues/427). New Y-axis formater for metric values that represent seconds, Thanks @jippi -- [Issue #390](https://github.com/grafana/grafana/issues/390). Allow special characters in serie names (influxdb datasource), Thanks @majst01 +- [Issue #390](https://github.com/grafana/grafana/issues/390). Allow special characters in series names (influxdb datasource), Thanks @majst01 - [Issue #428](https://github.com/grafana/grafana/issues/428). Refactoring of filterSrv, Thanks @Tetha - [Issue #445](https://github.com/grafana/grafana/issues/445). New config for playlist feature. Set playlist_timespan to set default playlist interval, Thanks @rmca - [Issue #461](https://github.com/grafana/grafana/issues/461). New graphite function definition added isNonNull, Thanks @tmonk42 @@ -1451,13 +1484,13 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele - [Issue #475](https://github.com/grafana/grafana/issues/475). Add panel icon and Row edit button is replaced by the Row edit menu - New graphs now have a default empty query - Add Row button now creates a row with default height of 250px (no longer opens dashboard settings modal) -- Clean up of config.sample.js, graphiteUrl removed (still works, but depricated, removed in future) +- Clean up of config.sample.js, graphiteUrl removed (still works, but deprecated, removed in future) Use datasources config instead. panel_names removed from config.js. Use plugins.panels to add custom panels - Graphite panel is now renamed graph (Existing dashboards will still work) #### Fixes - [Issue #126](https://github.com/grafana/grafana/issues/126). Graphite query lexer change, can now handle regex parameters for aliasSub function -- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having muliple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh inbetween. +- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having muliple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh in between. - [Issue #412](https://github.com/grafana/grafana/issues/412). After a filter option is changed and a nested template param is reloaded, if the current value exists after the options are reloaded the current selected value is kept. - [Issue #460](https://github.com/grafana/grafana/issues/460). Legend Current value did not display when value was zero - [Issue #328](https://github.com/grafana/grafana/issues/328). Fix to series toggling bug that caused annotations to be hidden when toggling/hiding series. diff --git a/appveyor.yml b/appveyor.yml index 5d67edca9d9..2b0bddde162 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "6" GOPATH: c:\gopath - GOVERSION: 1.9.2 + GOVERSION: 1.10 install: - rmdir c:\go /s /q diff --git a/build.go b/build.go index c38c452f61f..b86fc838e6b 100644 --- a/build.go +++ b/build.go @@ -41,6 +41,7 @@ var ( includeBuildNumber bool = true buildNumber int = 0 binaries []string = []string{"grafana-server", "grafana-cli"} + isDev bool = false ) const minGoVersion = 1.8 @@ -61,6 +62,7 @@ func main() { flag.BoolVar(&race, "race", race, "Use race detector") flag.BoolVar(&includeBuildNumber, "includeBuildNumber", includeBuildNumber, "IncludeBuildNumber in package name") flag.IntVar(&buildNumber, "buildNumber", 0, "Build number from CI system") + flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") flag.Parse() readVersionFromPackageJson() @@ -394,7 +396,9 @@ func build(binaryName, pkg string, tags []string) { binary += ".exe" } - rmr(binary, binary+".md5") + if !isDev { + rmr(binary, binary+".md5") + } args := []string{"build", "-ldflags", ldflags()} if len(tags) > 0 { args = append(args, "-tags", strings.Join(tags, ",")) @@ -405,16 +409,21 @@ func build(binaryName, pkg string, tags []string) { args = append(args, "-o", binary) args = append(args, pkg) - setBuildEnv() - runPrint("go", "version") + if !isDev { + setBuildEnv() + runPrint("go", "version") + } + runPrint("go", args...) - // Create an md5 checksum of the binary, to be included in the archive for - // automatic upgrades. - err := md5File(binary) - if err != nil { - log.Fatal(err) + if !isDev { + // Create an md5 checksum of the binary, to be included in the archive for + // automatic upgrades. + err := md5File(binary) + if err != nil { + log.Fatal(err) + } } } @@ -435,6 +444,10 @@ func rmr(paths ...string) { } func clean() { + if isDev { + return + } + rmr("dist") rmr("tmp") rmr(filepath.Join(os.Getenv("GOPATH"), fmt.Sprintf("pkg/%s_%s/github.com/grafana", goos, goarch))) @@ -550,7 +563,7 @@ func shaFilesInDist() { return nil } - if strings.Contains(path, ".sha256") == false { + if !strings.Contains(path, ".sha256") { err := shaFile(path) if err != nil { log.Printf("Failed to create sha file. error: %v\n", err) diff --git a/codecov.yml b/codecov.yml index 82a86e0232b..b2a839365ac 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,6 +8,4 @@ coverage: patch: yes changes: no -comment: - layout: "diff" - behavior: "once" +comment: off diff --git a/conf/sample.ini b/conf/sample.ini index 1af5bbdb62b..9f0c2a73c25 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -64,7 +64,7 @@ #################################### Database #################################### [database] # You can configure the database connection by specifying type, host, name, user and password -# as seperate properties or as on string using the url propertie. +# as separate properties or as on string using the url properties. # Either "mysql", "postgres" or "sqlite3", it's your choice ;type = sqlite3 diff --git a/docker/blocks/graphite/files/carbon.conf b/docker/blocks/graphite/files/carbon.conf index 50762b3fff5..fc03aba6398 100644 --- a/docker/blocks/graphite/files/carbon.conf +++ b/docker/blocks/graphite/files/carbon.conf @@ -38,7 +38,7 @@ CACHE_QUERY_PORT = 7002 LOG_UPDATES = False -# Enable AMQP if you want to receve metrics using an amqp broker +# Enable AMQP if you want to receive metrics using an amqp broker # ENABLE_AMQP = False # Verbose means a line will be logged for every metric received diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf b/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf index fc36328b25f..f8a53a61115 100644 --- a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf +++ b/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf @@ -41,7 +41,7 @@ PICKLE_RECEIVER_PORT = 2004 CACHE_QUERY_INTERFACE = 0.0.0.0 CACHE_QUERY_PORT = 7002 -# Enable AMQP if you want to receve metrics using you amqp broker +# Enable AMQP if you want to receive metrics using you amqp broker ENABLE_AMQP = True # Verbose means a line will be logged for every metric received diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf b/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf index 3e10dcec9cf..6741932da37 100644 --- a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf +++ b/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf @@ -265,7 +265,7 @@ WHISPER_FALLOCATE_CREATE = True # CARBON_METRIC_PREFIX = carbon # CARBON_METRIC_INTERVAL = 60 -# Enable AMQP if you want to receve metrics using an amqp broker +# Enable AMQP if you want to receive metrics using an amqp broker # ENABLE_AMQP = False # Verbose means a line will be logged for every metric received diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf b/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf index 2e1b0bc4db3..f558b273f57 100644 --- a/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf +++ b/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf @@ -30,7 +30,7 @@ give_completer_focus = shift-space # pertain only to specific metric types. # # The dashboard presents only metrics that fall into specified naming schemes -# defined in this file. This creates a simpler, more targetted view of the +# defined in this file. This creates a simpler, more targeted view of the # data. The general form for defining a naming scheme is as follows: # #[Metric Type] diff --git a/docker/blocks/mssql_tests/dashboard.json b/docker/blocks/mssql_tests/dashboard.json index 20e3907b48b..80994254093 100644 --- a/docker/blocks/mssql_tests/dashboard.json +++ b/docker/blocks/mssql_tests/dashboard.json @@ -100,7 +100,7 @@ "gnetId": null, "graphTooltip": 0, "id": null, - "iteration": 1521715844826, + "iteration": 1523320861623, "links": [], "panels": [ { @@ -443,7 +443,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -522,7 +526,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -601,7 +609,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -680,7 +692,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -759,7 +775,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -838,7 +858,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -927,7 +951,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1026,7 +1054,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1115,7 +1147,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1196,7 +1232,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1285,7 +1325,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1366,7 +1410,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1455,7 +1503,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1536,7 +1588,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1619,7 +1675,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1702,7 +1762,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1792,7 +1856,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1875,7 +1943,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1965,7 +2037,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2048,7 +2124,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2138,7 +2218,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2221,7 +2305,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2311,7 +2399,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2394,7 +2486,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "refresh": false, @@ -2504,5 +2600,5 @@ "timezone": "", "title": "Microsoft SQL Server Data Source Test", "uid": "GlAqcPgmz", - "version": 57 + "version": 58 } \ No newline at end of file diff --git a/docker/blocks/mysql/dashboard.json b/docker/blocks/mysql/dashboard.json index e2b791f82e6..dba7847cc72 100644 --- a/docker/blocks/mysql/dashboard.json +++ b/docker/blocks/mysql/dashboard.json @@ -2,7 +2,7 @@ "__inputs": [ { "name": "DS_MYSQL", - "label": "Mysql", + "label": "MySQL", "description": "", "type": "datasource", "pluginId": "mysql", @@ -20,19 +20,19 @@ "type": "panel", "id": "graph", "name": "Graph", - "version": "" + "version": "5.0.0" }, { "type": "datasource", "id": "mysql", "name": "MySQL", - "version": "1.0.0" + "version": "5.0.0" }, { "type": "panel", "id": "table", "name": "Table", - "version": "" + "version": "5.0.0" } ], "annotations": { @@ -53,7 +53,7 @@ "gnetId": null, "graphTooltip": 0, "id": null, - "iteration": 1518602729468, + "iteration": 1523372133566, "links": [], "panels": [ { @@ -118,7 +118,7 @@ ], "thresholds": [], "timeFrom": null, - "timeShift": "1h", + "timeShift": null, "title": "Average logins / $summarize", "tooltip": { "shared": true, @@ -150,7 +150,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -204,7 +208,7 @@ ], "thresholds": [], "timeFrom": null, - "timeShift": "1h", + "timeShift": null, "title": "Average payments started/ended / $summarize", "tooltip": { "shared": true, @@ -236,7 +240,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -284,7 +292,7 @@ ], "thresholds": [], "timeFrom": null, - "timeShift": "1h", + "timeShift": null, "title": "Max CPU / $summarize", "tooltip": { "shared": true, @@ -316,7 +324,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "columns": [], @@ -369,7 +381,7 @@ "target": "" } ], - "timeShift": "1h", + "timeShift": null, "title": "Values", "transform": "table", "type": "table" @@ -428,7 +440,6 @@ "auto_count": 5, "auto_min": "10s", "current": { - "selected": true, "text": "1m", "value": "1m" }, @@ -545,5 +556,5 @@ "timezone": "", "title": "Grafana Fake Data Gen - MySQL", "uid": "DGsCac3kz", - "version": 6 + "version": 8 } \ No newline at end of file diff --git a/docker/blocks/mysql/docker-compose.yaml b/docker/blocks/mysql/docker-compose.yaml index f7881e66539..53ff9da62a7 100644 --- a/docker/blocks/mysql/docker-compose.yaml +++ b/docker/blocks/mysql/docker-compose.yaml @@ -7,9 +7,6 @@ MYSQL_PASSWORD: password ports: - "3306:3306" - volumes: - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all] fake-mysql-data: diff --git a/docker/blocks/mysql_tests/Dockerfile b/docker/blocks/mysql_tests/Dockerfile new file mode 100644 index 00000000000..fa91fa3c023 --- /dev/null +++ b/docker/blocks/mysql_tests/Dockerfile @@ -0,0 +1,3 @@ +FROM mysql:latest +ADD setup.sql /docker-entrypoint-initdb.d +CMD ["mysqld"] \ No newline at end of file diff --git a/docker/blocks/mysql_tests/dashboard.json b/docker/blocks/mysql_tests/dashboard.json index 3ab08a7da35..53f313315bd 100644 --- a/docker/blocks/mysql_tests/dashboard.json +++ b/docker/blocks/mysql_tests/dashboard.json @@ -7,14 +7,6 @@ "type": "datasource", "pluginId": "mysql", "pluginName": "MySQL" - }, - { - "name": "DS_MSSQL_TEST", - "label": "MSSQL Test", - "description": "", - "type": "datasource", - "pluginId": "mssql", - "pluginName": "Microsoft SQL Server" } ], "__requires": [ @@ -30,12 +22,6 @@ "name": "Graph", "version": "5.0.0" }, - { - "type": "datasource", - "id": "mssql", - "name": "Microsoft SQL Server", - "version": "1.0.0" - }, { "type": "datasource", "id": "mysql", @@ -114,7 +100,7 @@ "gnetId": null, "graphTooltip": 0, "id": null, - "iteration": 1521715720483, + "iteration": 1523320712115, "links": [], "panels": [ { @@ -349,7 +335,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time_sec", + "pattern": "time", "type": "date" }, { @@ -457,7 +443,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -536,7 +526,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -615,7 +609,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -694,7 +692,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -773,7 +775,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -852,7 +858,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -941,7 +951,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1034,7 +1048,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1123,7 +1141,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1204,7 +1226,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1293,7 +1319,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1374,7 +1404,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1463,7 +1497,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1544,7 +1582,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1634,14 +1676,18 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "${DS_MYSQL_TEST}", "fill": 1, "gridPos": { "h": 8, @@ -1717,7 +1763,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1807,7 +1857,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1890,7 +1944,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1980,7 +2038,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2063,7 +2125,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2153,7 +2219,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2236,7 +2306,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "refresh": false, @@ -2315,8 +2389,8 @@ ] }, "time": { - "from": "2018-03-15T11:30:00.000Z", - "to": "2018-03-15T12:55:01.000Z" + "from": "2018-03-15T12:30:00.000Z", + "to": "2018-03-15T13:55:01.000Z" }, "timepicker": { "refresh_intervals": [ @@ -2346,5 +2420,5 @@ "timezone": "", "title": "MySQL Data Source Test", "uid": "Hmf8FDkmz", - "version": 9 + "version": 12 } \ No newline at end of file diff --git a/docker/blocks/mysql_tests/docker-compose.yaml b/docker/blocks/mysql_tests/docker-compose.yaml index 3c59b66b5ac..035a6167017 100644 --- a/docker/blocks/mysql_tests/docker-compose.yaml +++ b/docker/blocks/mysql_tests/docker-compose.yaml @@ -1,5 +1,6 @@ mysqltests: - image: mysql:latest + build: + context: blocks/mysql_tests environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: grafana_tests @@ -7,7 +8,4 @@ MYSQL_PASSWORD: password ports: - "3306:3306" - volumes: - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro tmpfs: /var/lib/mysql:rw diff --git a/docker/blocks/mysql_tests/setup.sql b/docker/blocks/mysql_tests/setup.sql new file mode 100644 index 00000000000..be917a1c542 --- /dev/null +++ b/docker/blocks/mysql_tests/setup.sql @@ -0,0 +1,2 @@ +CREATE DATABASE grafana_ds_tests; +GRANT ALL PRIVILEGES ON grafana_ds_tests.* TO 'grafana'; diff --git a/docker/blocks/postgres_tests/Dockerfile b/docker/blocks/postgres_tests/Dockerfile new file mode 100644 index 00000000000..afe4d199651 --- /dev/null +++ b/docker/blocks/postgres_tests/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:latest +ADD setup.sql /docker-entrypoint-initdb.d +CMD ["postgres"] \ No newline at end of file diff --git a/docker/blocks/postgres_tests/dashboard.json b/docker/blocks/postgres_tests/dashboard.json index eea95863716..9efbe90bdfe 100644 --- a/docker/blocks/postgres_tests/dashboard.json +++ b/docker/blocks/postgres_tests/dashboard.json @@ -100,7 +100,7 @@ "gnetId": null, "graphTooltip": 0, "id": null, - "iteration": 1521725946837, + "iteration": 1523320929325, "links": [], "panels": [ { @@ -443,7 +443,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -522,7 +526,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -601,7 +609,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -680,7 +692,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -759,7 +775,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -838,7 +858,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -927,7 +951,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1008,7 +1036,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1097,7 +1129,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1178,7 +1214,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1267,7 +1307,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1348,7 +1392,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1437,7 +1485,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1518,7 +1570,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1608,7 +1664,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1691,7 +1751,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1781,7 +1845,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1864,7 +1932,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -1954,7 +2026,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2037,7 +2113,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2127,7 +2207,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, @@ -2210,7 +2294,11 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "refresh": false, diff --git a/docker/blocks/postgres_tests/docker-compose.yaml b/docker/blocks/postgres_tests/docker-compose.yaml index 44b66e8e558..f5ce0a5a3d3 100644 --- a/docker/blocks/postgres_tests/docker-compose.yaml +++ b/docker/blocks/postgres_tests/docker-compose.yaml @@ -1,5 +1,6 @@ postgrestest: - image: postgres:latest + build: + context: blocks/postgres_tests environment: POSTGRES_USER: grafanatest POSTGRES_PASSWORD: grafanatest diff --git a/docker/blocks/postgres_tests/setup.sql b/docker/blocks/postgres_tests/setup.sql new file mode 100644 index 00000000000..b182b7c292d --- /dev/null +++ b/docker/blocks/postgres_tests/setup.sql @@ -0,0 +1,3 @@ +CREATE DATABASE grafanadstest; +REVOKE CONNECT ON DATABASE grafanadstest FROM PUBLIC; +GRANT CONNECT ON DATABASE grafanadstest TO grafanatest; \ No newline at end of file diff --git a/docker/blocks/smtp/bootstrap.sh b/docker/blocks/smtp/bootstrap.sh index a78f9d6dc16..27f6a2c3ef8 100755 --- a/docker/blocks/smtp/bootstrap.sh +++ b/docker/blocks/smtp/bootstrap.sh @@ -22,6 +22,6 @@ log() { log $RUN_CMD $RUN_CMD -# Exit immidiately in case of any errors or when we have interactive terminal +# Exit immediately in case of any errors or when we have interactive terminal if [[ $? != 0 ]] || test -t 0; then exit $?; fi log diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index b43ea68bcd8..23fbe0c89fd 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -138,6 +138,7 @@ datasources: ``` #### Custom Settings per Datasource +Please refer to each datasource documentation for specific provisioning examples. | Datasource | Misc | | ---- | ---- | @@ -205,7 +206,7 @@ When Grafana starts, it will update/insert all dashboards available in the confi ### 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. +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 identifier. 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. diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index bb119687750..b3b4305a748 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -153,10 +153,10 @@ Prometheus Alertmanager | `prometheus-alertmanager` | no # Enable images in notifications {#external-image-store} -Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports +Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. -Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If your using local image uploader, your Grafana instance need to be accessible by the internet. +Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If you're using local image uploader, your Grafana instance need to be accessible by the internet. Currently only the Email Channels attaches images if no external image store is specified. To include images in alert notifications for other channels then you need to set up an external image store. diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index 9bbbd70641d..bcca3c6b2fb 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -110,7 +110,7 @@ to `Keep Last State` in order to basically ignore them. ## Notifications -In alert tab you can also specify alert rule notifications along with a detailed messsage about the alert rule. +In alert tab you can also specify alert rule notifications along with a detailed message about the alert rule. The message can contain anything, information about how you might solve the issue, link to runbook, etc. The actual notifications are configured and shared between multiple alerts. Read the diff --git a/docs/sources/contribute/cla.md b/docs/sources/contribute/cla.md index b990187d809..ffb2aaef1b9 100644 --- a/docs/sources/contribute/cla.md +++ b/docs/sources/contribute/cla.md @@ -1,6 +1,6 @@ +++ title = "Contributor Licence Agreement (CLA)" -description = "Contributer Licence Agreement (CLA)" +description = "Contributor Licence Agreement (CLA)" type = "docs" aliases = ["/project/cla", "docs/contributing/cla.html"] [menu.docs] @@ -101,4 +101,4 @@ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU [OR US]


-This CLA aggreement is based on the [Harmony Contributor Aggrement Template (combined)](http://www.harmonyagreements.org/agreements.html), [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/) +This CLA agreement is based on the [Harmony Contributor Aggrement Template (combined)](http://www.harmonyagreements.org/agreements.html), [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/) diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index f7f8138b5e9..f5586bef2d8 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -43,6 +43,40 @@ server is running on AWS you can use IAM Roles and authentication will be handle Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) +## IAM Policies + +Grafana needs permissions granted via IAM to be able to read CloudWatch metrics +and EC2 tags/instances. You can attach these permissions to IAM roles and +utilize Grafana's built-in support for assuming roles. + +Here is a minimal policy example: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadingMetricsFromCloudWatch", + "Effect": "Allow", + "Action": [ + "cloudwatch:ListMetrics", + "cloudwatch:GetMetricStatistics" + ], + "Resource": "*" + }, + { + "Sid": "AllowReadingTagsFromEC2", + "Effect": "Allow", + "Action": [ + "ec2:DescribeTags", + "ec2:DescribeInstances" + ], + "Resource": "*" + } + ] +} +``` + ### AWS credentials file Create a file at `~/.aws/credentials`. That is the `HOME` path for user running grafana-server. @@ -173,3 +207,37 @@ Amazon provides 1 million CloudWatch API requests each month at no additional ch it costs $0.01 per 1,000 GetMetricStatistics or ListMetrics requests. For each query Grafana will issue a GetMetricStatistics request and every time you pick a dimension in the query editor Grafana will issue a ListMetrics request. + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +Using a credentials file +```yaml +apiVersion: 1 + +datasources: + - name: Cloudwatch + type: cloudwatch + jsonData: + authType: credentials + defaultRegion: eu-west-2 +``` + +Using `accessKey` and `secretKey` + +```yaml +apiVersion: 1 + +datasources: + - name: Cloudwatch + type: cloudwatch + jsonData: + authType: keys + defaultRegion: eu-west-2 + secureJsonData: + accessKey: "" + secretKey: "" +``` diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 6ce17113a9b..1a00c2b18ff 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -55,6 +55,22 @@ a time pattern for the index name or a wildcard. Be sure to specify your Elasticsearch version in the version selection dropdown. This is very important as there are differences how queries are composed. Currently only 2.x and 5.x are supported. +### Min time interval +A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example `1m` if your data is written every minute. +This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a +number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported: + +Identifier | Description +------------ | ------------- +`y` | year +`M` | month +`w` | week +`d` | day +`h` | hour +`m` | minute +`s` | second +`ms` | millisecond + ## Metric Query editor ![](/img/docs/elasticsearch/query_editor.png) @@ -137,3 +153,23 @@ Query | You can leave the search query blank or specify a lucene query Time | The name of the time field, needs to be date field. Text | Event description field. Tags | Optional field name to use for event tags (can be an array or a CSV string). + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: Elastic + type: elasticsearch + access: proxy + database: "[metrics-]YYYY.MM.DD" + url: http://localhost:9200 + jsonData: + interval: Daily + timeField: "@timestamp" +``` diff --git a/docs/sources/features/datasources/graphite.md b/docs/sources/features/datasources/graphite.md index 7c4187da9ae..97233572878 100644 --- a/docs/sources/features/datasources/graphite.md +++ b/docs/sources/features/datasources/graphite.md @@ -120,3 +120,21 @@ queries via the Dashboard menu / Annotations view. Graphite supports two ways to query annotations. A regular metric query, for this you use the `Graphite query` textbox. A Graphite events query, use the `Graphite event tags` textbox, specify a tag or wildcard (leave empty should also work) + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: Graphite + type: graphite + access: proxy + url: http://localhost:8080 + jsonData: + graphiteVersion: "1.1" +``` diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index 6d0918a0d01..d108c97bbfe 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -39,6 +39,22 @@ Proxy access means that the Grafana backend will proxy all requests from the bro `grafana-server`. This means that the URL you specify needs to be accessible from the server you are running Grafana on. Proxy access mode is also more secure as the username & password will never reach the browser. +### Min time interval +A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example `1m` if your data is written every minute. +This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a +number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported: + +Identifier | Description +------------ | ------------- +`y` | year +`M` | month +`w` | week +`d` | day +`h` | hour +`m` | minute +`s` | second +`ms` | millisecond + ## Query Editor {{< docs-imagebox img="/img/docs/v45/influxdb_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v45/influxdb_query.gif" >}} @@ -174,3 +190,22 @@ SELECT title, description from events WHERE $timeFilter order asc For InfluxDB you need to enter a query like in the above example. You need to have the ```where $timeFilter``` part. If you only select one column you will not need to enter anything in the column mapping fields. The Tags field can be a comma separated string. + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: InfluxDB + type: influxdb + access: proxy + database: site + user: grafana + password: grafana + url: http://localhost:8086 +``` diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index 7fae7441b6d..8eaab86a906 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -225,3 +225,21 @@ tags | Optional field name to use for event tags as a comma separated string. ## Alerting Time series queries should work in alerting conditions. Table formatted queries is not yet supported in alert rule conditions. + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: MySQL + type: mysql + url: localhost:3306 + database: grafana + user: grafana + password: password +``` \ No newline at end of file diff --git a/docs/sources/features/datasources/opentsdb.md b/docs/sources/features/datasources/opentsdb.md index 03795473ff7..0959817c015 100644 --- a/docs/sources/features/datasources/opentsdb.md +++ b/docs/sources/features/datasources/opentsdb.md @@ -78,7 +78,7 @@ the existing time series data in OpenTSDB, you need to run `tsdb uid metasync` o ### Nested Templating -One template variable can be used to filter tag values for another template varible. First parameter is the metric name, +One template variable can be used to filter tag values for another template variable. First parameter is the metric name, second parameter is the tag key for which you need to find tag values, and after that all other dependent template variables. Some examples are mentioned below to make nested template queries work successfully. @@ -88,3 +88,22 @@ Query | Description *tag_values(cpu, hostanme, env=$env, region=$region)* | Return tag values for cpu metric, selected env tag value, selected region tag value and tag key hostname For details on OpenTSDB metric queries checkout the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: OpenTsdb + type: opentsdb + access: proxy + url: http://localhost:4242 + jsonData: + tsdbResolution: 1 + tsdbVersion: 1 +``` diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index 7d52df2fd3e..f3321af46f2 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -45,9 +45,9 @@ Macro example | Description ------------ | ------------- *$__time(dateColumn)* | Will be replaced by an expression to rename the column to `time`. For example, *dateColumn as time* *$__timeSec(dateColumn)* | Will be replaced by an expression to rename the column to `time` and converting the value to unix timestamp. For example, *extract(epoch from dateColumn) as time* -*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *extract(epoch from dateColumn) BETWEEN 1494410783 AND 1494497183* -*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *to_timestamp(1494410783)* -*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *to_timestamp(1494497183)* +*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* +*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* +*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* *$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300 AS time* *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* @@ -217,3 +217,25 @@ tags | Optional field name to use for event tags as a comma separated string. Time series queries should work in alerting conditions. Table formatted queries is not yet supported in alert rule conditions. + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: Postgres + type: postgres + url: localhost:5432 + database: grafana + user: grafana + secureJsonData: + password: "Password!" + jsonData: + sslmode: "disable" # disable/require/verify-ca/verify-full + +``` \ No newline at end of file diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index c9bb16441ca..17a24ed0684 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -34,7 +34,7 @@ Name | Description *Basic Auth* | Enable basic authentication to the Prometheus data source. *User* | Name of your Prometheus user *Password* | Database user's password -*Scrape interval* | This will be used as a lower limit for the Prometheus step query parameter. Default value is 15s. +*Scrape interval* | This will be used as a lower limit for the Prometheus step query parameter. Default value is 15s. ## Query editor @@ -100,3 +100,19 @@ The step option is useful to limit the number of events returned from your query ## Getting Grafana metrics into Prometheus Since 4.6.0 Grafana exposes metrics for Prometheus on the `/metrics` endpoint. We also bundle a dashboard within Grafana so you can get started viewing your metrics faster. You can import the bundled dashboard by going to the data source edit page and click the dashboard tab. There you can find a dashboard for Grafana and one for Prometheus. Import and start viewing all the metrics! + +## Configure datasource with provisioning + +It's now possible to configure datasources using config files with Grafanas provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) + +Here are some provisioning examples for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://localhost:9090 +``` \ No newline at end of file diff --git a/docs/sources/features/panels/alertlist.md b/docs/sources/features/panels/alertlist.md index 9307bb71391..58aa2c0966a 100644 --- a/docs/sources/features/panels/alertlist.md +++ b/docs/sources/features/panels/alertlist.md @@ -14,7 +14,7 @@ weight = 4 {{< docs-imagebox img="/img/docs/v45/alert-list-panel.png" max-width="850px" >}} -The alert list panel allows you to display your dashbords alerts. The list can be configured to show current state or recent state changes. You can read more about alerts [here](http://docs.grafana.org/alerting/rules). +The alert list panel allows you to display your dashboards alerts. The list can be configured to show current state or recent state changes. You can read more about alerts [here](http://docs.grafana.org/alerting/rules). ## Alert List Options diff --git a/docs/sources/features/panels/dashlist.md b/docs/sources/features/panels/dashlist.md index 8a4ed60875d..2ee578c5b7e 100644 --- a/docs/sources/features/panels/dashlist.md +++ b/docs/sources/features/panels/dashlist.md @@ -25,7 +25,7 @@ The dashboard list panel allows you to display dynamic links to other dashboards 1. **Starred**: The starred dashboard selection displays starred dashboards in alphabetical order. 2. **Recently Viewed**: The recently viewed dashboard selection displays recently viewed dashboards in alphabetical order. 3. **Search**: The search dashboard selection displays dashboards by search query or tag(s). -4. **Show Headings**: When show headings is ticked the choosen list selection(Starred, Recently Viewed, Search) is shown as a heading. +4. **Show Headings**: When show headings is ticked the chosen list selection(Starred, Recently Viewed, Search) is shown as a heading. 5. **Max Items**: Max items set the maximum of items in a list. 6. **Query**: Here is where you enter your query you want to search by. Queries are case-insensitive, and partial values are accepted. 7. **Tags**: Here is where you enter your tag(s) you want to search by. Note that existing tags will not appear as you type, and *are* case sensitive. To see a list of existing tags, you can always return to the dashboard, open the Dashboard Picker at the top and click `tags` link in the search bar. diff --git a/docs/sources/features/panels/singlestat.md b/docs/sources/features/panels/singlestat.md index 510642337ff..0eb442914f5 100644 --- a/docs/sources/features/panels/singlestat.md +++ b/docs/sources/features/panels/singlestat.md @@ -30,7 +30,7 @@ The singlestat panel has a normal query editor to allow you define your exact me * **total** - The sum of all the non-null values in the series * **first** - The first value in the series * **delta** - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series. - * **diff** - The difference betwen 'current' (last value) and 'first'. + * **diff** - The difference between 'current' (last value) and 'first'. * **range** - The difference between 'min' and 'max'. Useful the show the range of change for a gauge. 2. **Prefix/Postfix**: The Prefix/Postfix fields let you define a custom label to appear *before/after* the value. The `$__name` variable can be used here to use the series name or alias from the metric query. 3. **Units**: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value. @@ -70,7 +70,7 @@ Gauges gives a clear picture of how high a value is in it's context. It's a grea {{< docs-imagebox img="/img/docs/v45/singlestat-gauge-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}} -1. **Show**: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear. +1. **Show**: The show checkbox will toggle whether the gauge is shown in the panel. When unselected, only the Singlestat value will appear. 2. **Min/Max**: This sets the start and end point for the gauge. 3. **Threshold Labels**: Check if you want to show the threshold labels. Thresholds are set in the color options. 4. **Threshold Markers**: Check if you want to have a second meter showing the thresholds. diff --git a/docs/sources/guides/whats-new-in-v2-6.md b/docs/sources/guides/whats-new-in-v2-6.md index b8996680ce6..1e6f30c597b 100644 --- a/docs/sources/guides/whats-new-in-v2-6.md +++ b/docs/sources/guides/whats-new-in-v2-6.md @@ -15,7 +15,7 @@ support for multiple Cloudwatch credentials. The new table panel is very flexible, supporting both multiple modes for time series as well as for -table, annotation and raw JSON data. It also provides date formating and value formating and coloring options. +table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. ### Time series to rows diff --git a/docs/sources/guides/whats-new-in-v4-1.md b/docs/sources/guides/whats-new-in-v4-1.md index bd2b0f1b75f..217b21b545e 100644 --- a/docs/sources/guides/whats-new-in-v4-1.md +++ b/docs/sources/guides/whats-new-in-v4-1.md @@ -33,7 +33,7 @@ You can enable/disable the shared tooltip from the dashboard settings menu or cy {{< imgbox max-width="60%" img="/img/docs/v41/helptext_for_panel_settings.png" caption="Hovering help text" >}} -You can set a help text in the general tab on any panel. The help text is using Markdown to enable better formating and linking to other sites that can provide more information. +You can set a help text in the general tab on any panel. The help text is using Markdown to enable better formatting and linking to other sites that can provide more information.
diff --git a/docs/sources/guides/whats-new-in-v4-5.md b/docs/sources/guides/whats-new-in-v4-5.md index b2de451308a..a5cd3ca982d 100644 --- a/docs/sources/guides/whats-new-in-v4-5.md +++ b/docs/sources/guides/whats-new-in-v4-5.md @@ -12,7 +12,7 @@ weight = -4 # What's New in Grafana v4.5 -## Hightlights +## Highlights ### New prometheus query editor @@ -62,7 +62,7 @@ Datas source selection & options & help are now above your metric queries. ### Minor Changes * **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) -* **InfluxDB**: Added paranthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) +* **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) ## Bug Fixes diff --git a/docs/sources/guides/whats-new-in-v4-6.md b/docs/sources/guides/whats-new-in-v4-6.md index fd75384761f..09955fa58cc 100644 --- a/docs/sources/guides/whats-new-in-v4-6.md +++ b/docs/sources/guides/whats-new-in-v4-6.md @@ -45,7 +45,7 @@ This makes exploring and filtering Prometheus data much easier. * **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) * **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) -* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +* **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) * **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) * **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) * **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index 19c2a5c386c..0dc89912c05 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -36,6 +36,8 @@ Query Parameters: - `alertId`: number. Optional. Find annotations for a specified alert. - `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard - `panelId`: number. Optional. Find annotations that are scoped to a specific panel +- `userId`: number. Optional. Find annotations created by a specific user +- `type`: string. Optional. `alert`|`annotation` Return alerts or user created annotations - `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`. **Example Response**: @@ -180,14 +182,14 @@ Content-Type: application/json ## Delete Annotation By Id -`DELETE /api/annotation/:id` +`DELETE /api/annotations/:id` Deletes the annotation that matches the specified id. **Example Request**: ```http -DELETE /api/annotation/1 HTTP/1.1 +DELETE /api/annotations/1 HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -204,14 +206,14 @@ Content-Type: application/json ## Delete Annotation By RegionId -`DELETE /api/annotation/region/:id` +`DELETE /api/annotations/region/:id` Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id. **Example Request**: ```http -DELETE /api/annotation/region/1 HTTP/1.1 +DELETE /api/annotations/region/1 HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 364b55b0cfc..9aaf29ec5f4 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "defaultRegion": "us-west-1" }, "secureJsonData": { - "accessKey": "Ol4pIDpeKSA6XikgOl4p", - "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" + "accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded + "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded } } ``` diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 4c1dff904c8..b9a15450786 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -307,7 +307,7 @@ Content-Type: application/json `PUT /api/orgs/:orgId` -Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented yet. +Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented yet. **Example Request**: @@ -436,4 +436,4 @@ HTTP/1.1 200 Content-Type: application/json {"message":"User removed from organization"} -``` \ No newline at end of file +``` diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 6169280b798..b7fe9040574 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -482,7 +482,7 @@ Set api_url to the resource that returns [OpenID UserInfo](https://connect2id.co First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https:///` and set the Login redirect URIs to `https:///login/generic_oauth`. -Finaly set up the generic oauth module like this: +Finally set up the generic oauth module like this: ```bash [auth.generic_oauth] name = Okta diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 27c0e41ac15..8b2e15ad124 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,6 +16,7 @@ weight = 1 Description | Download ------------ | ------------- 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) +Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -29,12 +30,19 @@ sudo apt-get install -y adduser libfontconfig 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.1.0-beta1_amd64.deb +sudo apt-get install -y adduser libfontconfig +sudo dpkg -i grafana_5.1.0-beta1_amd64.deb +``` + ## APT Repository Add the following line to your `/etc/apt/sources.list` file. ```bash -deb https://packagecloud.io/grafana/stable/debian/ jessie main +deb https://packagecloud.io/grafana/stable/debian/ stretch main ``` Use the above line even if you are on Ubuntu or another Debian version. @@ -42,7 +50,7 @@ There is also a testing repository if you want beta or release candidates. ```bash -deb https://packagecloud.io/grafana/testing/debian/ jessie main +deb https://packagecloud.io/grafana/testing/debian/ stretch main ``` Then add the [Package Cloud](https://packagecloud.io/grafana) key. This diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 3ca5ba06638..f246bd55d33 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -12,7 +12,7 @@ weight = 4 # Installing using Docker -Grafana is very easy to install and run using the offical docker container. +Grafana is very easy to install and run using the official docker container. ```bash $ docker run -d -p 3000:3000 grafana/grafana diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index 05192512e5a..3650560d5cf 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,6 +16,7 @@ weight = 2 Description | Download ------------ | ------------- 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) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing @@ -29,6 +30,12 @@ You can install Grafana using Yum directly. $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.4-1.x86_64.rpm ``` +## Install Beta + +```bash +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm +``` + Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 49cdd4ca1d3..c72bb4c0921 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -25,7 +25,7 @@ Before upgrading it can be a good idea to backup your Grafana database. This wil 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`. +installed grafana to custom location using a binary tar/zip it is usually in `/data`. #### mysql diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 4f8d7696b57..31fe243c01d 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,6 +13,7 @@ weight = 3 Description | Download ------------ | ------------- 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) +Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/reference/playlist.md b/docs/sources/reference/playlist.md index 5a6bf921334..182e69eebd0 100644 --- a/docs/sources/reference/playlist.md +++ b/docs/sources/reference/playlist.md @@ -49,7 +49,7 @@ Click the back button to rewind to the previous Dashboard in the Playlist. In TV mode the top navbar, row & panel controls will all fade to transparent. This happens automatically after one minute of user inactivity but can also be toggled manually -with the `d v` sequence shortcut. Any mouse movement or keyboard action will +with the `d v` sequence shortcut, or by appending the parameter `?inactive` to the dashboard URL. Any mouse movement or keyboard action will restore navbar & controls. Another feature is the kiosk mode - in kiosk mode the navbar is completely hidden/removed from view. This can be enabled with the `d k` diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 016d64d9ee9..6dbc9cc9d11 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -168,7 +168,7 @@ Option | Description *Include All option* | Add a special `All` option whose value includes all options. *Custom all value* | By default the `All` value will include all options in combined expression. This can become very long and can have performance problems. Many times it can be better to specify a custom all value, like a wildcard regex. To make it possible to have custom regex, globs or lucene syntax in the **Custom all value** option it is never escaped so you will have to think avbout what is a valid value for your data source. -### Formating multiple values +### Formatting multiple values Interpolating a variable with multiple values selected is tricky as it is not straight forward how to format the multiple values to into a string that is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to @@ -186,7 +186,7 @@ break the regex expression. **Elasticsearch** uses lucene query syntax, so the same variable would, in this case, be formatted as `("host1" OR "host2" OR "host3")`. In this case every value needs to be escaped so that the value can contain lucene control words and quotation marks. -#### Formating troubles +#### Formatting troubles Automatic escaping & formatting can cause problems and it can be tricky to grasp the logic is behind it. Especially for InfluxDB and Prometheus where the use of regex syntax requires that the variable is used in regex operator context. diff --git a/docs/sources/tutorials/authproxy.md b/docs/sources/tutorials/authproxy.md index 8003be20644..6f13de85c18 100644 --- a/docs/sources/tutorials/authproxy.md +++ b/docs/sources/tutorials/authproxy.md @@ -108,7 +108,7 @@ In this example we use Apache as a reverseProxy in front of Grafana. Apache hand * The next part of the configuration is the tricky part. We use Apache’s rewrite engine to create our **X-WEBAUTH-USER header**, populated with the authenticated user. - * **RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER}, NS]**: This line is a little bit of magic. What it does, is for every request use the rewriteEngines look-ahead (LA-U) feature to determine what the REMOTE_USER variable would be set to after processing the request. Then assign the result to the variable PROXY_USER. This is neccessary as the REMOTE_USER variable is not available to the RequestHeader function. + * **RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER}, NS]**: This line is a little bit of magic. What it does, is for every request use the rewriteEngines look-ahead (LA-U) feature to determine what the REMOTE_USER variable would be set to after processing the request. Then assign the result to the variable PROXY_USER. This is necessary as the REMOTE_USER variable is not available to the RequestHeader function. * **RequestHeader set X-WEBAUTH-USER “%{PROXY_USER}e”**: With the authenticated username now stored in the PROXY_USER variable, we create a new HTTP request header that will be sent to our backend Grafana containing the username. @@ -149,7 +149,7 @@ auto_sign_up = true ##### Grafana Container -For this example, we use the offical Grafana docker image available at [Docker Hub](https://hub.docker.com/r/grafana/grafana/) +For this example, we use the official Grafana docker image available at [Docker Hub](https://hub.docker.com/r/grafana/grafana/) * Create a file `grafana.ini` with the following contents @@ -166,7 +166,7 @@ header_property = username auto_sign_up = true ``` -* Launch the Grafana container, using our custom grafana.ini to replace `/etc/grafana/grafana.ini`. We dont expose any ports for this container as it will only be connected to by our Apache container. +* Launch the Grafana container, using our custom grafana.ini to replace `/etc/grafana/grafana.ini`. We don't expose any ports for this container as it will only be connected to by our Apache container. ```bash docker run -i -v $(pwd)/grafana.ini:/etc/grafana/grafana.ini --name grafana grafana/grafana @@ -174,7 +174,7 @@ docker run -i -v $(pwd)/grafana.ini:/etc/grafana/grafana.ini --name grafana graf ### Apache Container -For this example we use the offical Apache docker image available at [Docker Hub](https://hub.docker.com/_/httpd/) +For this example we use the official Apache docker image available at [Docker Hub](https://hub.docker.com/_/httpd/) * Create a file `httpd.conf` with the following contents @@ -244,4 +244,4 @@ ProxyPassReverse / http://grafana:3000/ ### Use grafana. -With our Grafana and Apache containers running, you can now connect to http://localhost/ and log in using the username/password we created in the htpasswd file. \ No newline at end of file +With our Grafana and Apache containers running, you can now connect to http://localhost/ and log in using the username/password we created in the htpasswd file. diff --git a/package.json b/package.json index 030219fe587..ce861a25f7b 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "angular-route": "^1.6.6", "angular-sanitize": "^1.6.6", "babel-polyfill": "^6.26.0", + "baron": "^3.0.3", "brace": "^0.10.0", "classnames": "^2.2.5", "clipboard": "^1.7.1", @@ -151,7 +152,6 @@ "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", diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 08ba2a89dd9..9fd4e1f93b9 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-beta5 -rpm_ver=5.0.0-beta5 +deb_ver=5.1.0-beta1 +rpm_ver=5.1.0-beta1 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 886913d6324..fdf577a6a6f 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -2,7 +2,6 @@ package api import ( "strings" - "time" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" @@ -15,9 +14,10 @@ import ( func GetAnnotations(c *m.ReqContext) Response { query := &annotations.ItemQuery{ - From: c.QueryInt64("from") / 1000, - To: c.QueryInt64("to") / 1000, + From: c.QueryInt64("from"), + To: c.QueryInt64("to"), OrgId: c.OrgId, + UserId: c.QueryInt64("userId"), AlertId: c.QueryInt64("alertId"), DashboardId: c.QueryInt64("dashboardId"), PanelId: c.QueryInt64("panelId"), @@ -37,7 +37,7 @@ func GetAnnotations(c *m.ReqContext) Response { if item.Email != "" { item.AvatarUrl = dtos.GetGravatarUrl(item.Email) } - item.Time = item.Time * 1000 + item.Time = item.Time } return JSON(200, items) @@ -68,16 +68,12 @@ func PostAnnotation(c *m.ReqContext, cmd dtos.PostAnnotationsCmd) Response { UserId: c.UserId, DashboardId: cmd.DashboardId, PanelId: cmd.PanelId, - Epoch: cmd.Time / 1000, + Epoch: cmd.Time, Text: cmd.Text, Data: cmd.Data, Tags: cmd.Tags, } - if item.Epoch == 0 { - item.Epoch = time.Now().Unix() - } - if err := repo.Save(&item); err != nil { return Error(500, "Failed to save annotation", err) } @@ -97,7 +93,7 @@ func PostAnnotation(c *m.ReqContext, cmd dtos.PostAnnotationsCmd) Response { } item.Id = 0 - item.Epoch = cmd.TimeEnd / 1000 + item.Epoch = cmd.TimeEnd if err := repo.Save(&item); err != nil { return Error(500, "Failed save annotation for region end time", err) @@ -132,9 +128,6 @@ func PostGraphiteAnnotation(c *m.ReqContext, cmd dtos.PostGraphiteAnnotationsCmd return Error(500, "Failed to save Graphite annotation", err) } - if cmd.When == 0 { - cmd.When = time.Now().Unix() - } text := formatGraphiteAnnotation(cmd.What, cmd.Data) // Support tags in prior to Graphite 0.10.0 format (string of tags separated by space) @@ -163,7 +156,7 @@ func PostGraphiteAnnotation(c *m.ReqContext, cmd dtos.PostGraphiteAnnotationsCmd item := annotations.Item{ OrgId: c.OrgId, UserId: c.UserId, - Epoch: cmd.When, + Epoch: cmd.When * 1000, Text: text, Tags: tagsArray, } @@ -191,7 +184,7 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response { OrgId: c.OrgId, UserId: c.UserId, Id: annotationID, - Epoch: cmd.Time / 1000, + Epoch: cmd.Time, Text: cmd.Text, Tags: cmd.Tags, } @@ -203,7 +196,7 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response { if cmd.IsRegion { itemRight := item itemRight.RegionId = item.Id - itemRight.Epoch = cmd.TimeEnd / 1000 + itemRight.Epoch = cmd.TimeEnd // We don't know id of region right event, so set it to 0 and find then using query like // ... WHERE region_id = AND id != ... diff --git a/pkg/api/api.go b/pkg/api/api.go index 3c7b81e472d..96b764b95b9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -149,8 +149,6 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute RouteRegister) { - teamsRoute.Get("/:teamId", wrap(GetTeamByID)) - teamsRoute.Get("/search", wrap(SearchTeams)) teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam)) teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID)) @@ -159,6 +157,12 @@ func (hs *HTTPServer) registerRoutes() { teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) }, reqOrgAdmin) + // team without requirement of user to be org admin + apiRoute.Group("/teams", func(teamsRoute RouteRegister) { + teamsRoute.Get("/:teamId", wrap(GetTeamByID)) + teamsRoute.Get("/search", wrap(SearchTeams)) + }) + // org information available to all users. apiRoute.Group("/org", func(orgRoute RouteRegister) { orgRoute.Get("/", wrap(GetOrgCurrent)) @@ -170,7 +174,6 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent)) orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent)) orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg)) - orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg)) orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) @@ -184,6 +187,11 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateOrgPreferences)) }, reqOrgAdmin) + // current org without requirement of user to be org admin + apiRoute.Group("/org", func(orgRoute RouteRegister) { + orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg)) + }) + // create new org apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg)) diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index ce9da1e8790..9f282794076 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -258,9 +258,6 @@ func (this *thunderTask) fetch() error { this.Avatar.data = &bytes.Buffer{} writer := bufio.NewWriter(this.Avatar.data) - if _, err = io.Copy(writer, resp.Body); err != nil { - return err - } - - return nil + _, err = io.Copy(writer, resp.Body) + return err } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 11a028cdd29..c2ab6dd9a1a 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -102,6 +102,16 @@ func GetDashboard(c *m.ReqContext) Response { meta.FolderUrl = query.Result.GetUrl() } + isDashboardProvisioned := &m.IsDashboardProvisionedQuery{DashboardId: dash.Id} + err = bus.Dispatch(isDashboardProvisioned) + if err != nil { + return Error(500, "Error while checking if dashboard is provisioned", err) + } + + if isDashboardProvisioned.Result { + meta.Provisioned = true + } + // make sure db version is in sync with json model version dash.Data.Set("version", dash.Version) @@ -228,7 +238,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { err == m.ErrDashboardWithSameUIDExists || err == m.ErrFolderNotFound || err == m.ErrDashboardFolderCannotHaveParent || - err == m.ErrDashboardFolderNameExists { + err == m.ErrDashboardFolderNameExists || + err == m.ErrDashboardCannotSaveProvisionedDashboard { return Error(400, err.Error(), nil) } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 653815aea5c..342eaf556c6 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -29,6 +29,11 @@ func GetDashboardPermissionList(c *m.ReqContext) Response { } for _, perm := range acl { + perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail) + + if perm.TeamId > 0 { + perm.TeamAvatarUrl = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team) + } if perm.Slug != "" { perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) } diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index bdf80ef5241..24f0bdca365 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -143,7 +143,7 @@ func TestDashboardPermissionApiEndpoint(t *testing.T) { }) }) - Convey("When trying to override inherited permissions with lower presedence", func() { + Convey("When trying to override inherited permissions with lower precedence", func() { origNewGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ CanAdminValue: true, diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 0d87023ce40..ccde2382787 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -42,6 +42,11 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { + query.Result = false + return nil + }) + viewerRole := m.ROLE_VIEWER editorRole := m.ROLE_EDITOR @@ -192,6 +197,11 @@ func TestDashboardApiEndpoint(t *testing.T) { fakeDash.HasAcl = true setting.ViewersCanEdit = false + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { + query.Result = false + return nil + }) + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { dashboards := []*m.Dashboard{fakeDash} query.Result = dashboards @@ -625,6 +635,11 @@ func TestDashboardApiEndpoint(t *testing.T) { dashTwo.FolderId = 3 dashTwo.HasAcl = false + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { + query.Result = false + return nil + }) + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { dashboards := []*m.Dashboard{dashOne, dashTwo} query.Result = dashboards @@ -720,6 +735,7 @@ func TestDashboardApiEndpoint(t *testing.T) { {SaveError: m.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: 403}, {SaveError: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, {SaveError: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {SaveError: m.ErrDashboardCannotSaveProvisionedDashboard, ExpectedStatusCode: 400}, {SaveError: m.UpdatePluginDashboardError{PluginId: "plug"}, ExpectedStatusCode: 412}, } @@ -750,6 +766,11 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { + query.Result = false + return nil + }) + bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { query.Result = &m.DashboardVersion{ Data: simplejson.NewFromAny(map[string]interface{}{ diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index e4c66aebbda..39a6dca580d 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -28,6 +28,7 @@ type DashboardMeta struct { FolderId int64 `json:"folderId"` FolderTitle string `json:"folderTitle"` FolderUrl string `json:"folderUrl"` + Provisioned bool `json:"provisioned"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 0d0904c99ea..d19ec848ab2 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -33,6 +33,12 @@ func GetFolderPermissionList(c *m.ReqContext) Response { perm.FolderId = folder.Id perm.DashboardId = 0 + perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail) + + if perm.TeamId > 0 { + perm.TeamAvatarUrl = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team) + } + if perm.Slug != "" { perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 387a543ca89..8e01e869329 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -139,7 +139,7 @@ func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error { } hs.httpSrv.TLSConfig = tlsCfg - hs.httpSrv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0) + hs.httpSrv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) return hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) } diff --git a/pkg/api/index.go b/pkg/api/index.go index a1d21d1c686..94094706f68 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -118,9 +118,14 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { }) if c.IsSignedIn { + // Only set login if it's different from the name + var login string + if c.SignedInUser.Login != c.SignedInUser.NameOrFallback() { + login = c.SignedInUser.Login + } profileNode := &dtos.NavLink{ Text: c.SignedInUser.NameOrFallback(), - SubTitle: c.SignedInUser.Login, + SubTitle: login, Id: "profile", Img: data.User.GravatarUrl, Url: setting.AppSubUrl + "/profile", @@ -284,6 +289,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Help", + SubTitle: fmt.Sprintf(`Grafana v%s (%s)`, setting.BuildVersion, setting.BuildCommit), Id: "help", Url: "#", Icon: "gicon gicon-question", diff --git a/pkg/api/login.go b/pkg/api/login.go index 671e5fb7ecd..9d0fa31946f 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -101,13 +101,14 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { return Error(401, "Login is disabled", nil) } - authQuery := login.LoginUserQuery{ - Username: cmd.User, - Password: cmd.Password, - IpAddress: c.Req.RemoteAddr, + authQuery := &m.LoginUserQuery{ + ReqContext: c, + Username: cmd.User, + Password: cmd.Password, + IpAddress: c.Req.RemoteAddr, } - if err := bus.Dispatch(&authQuery); err != nil { + if err := bus.Dispatch(authQuery); err != nil { if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { return Error(401, "Invalid username or password", err) } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 1dba38e9cbd..c4a5f8fdacf 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -6,7 +6,6 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" - "errors" "fmt" "io/ioutil" "net/http" @@ -16,22 +15,15 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" 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" ) -var ( - ErrProviderDeniedRequest = errors.New("Login provider denied login request") - ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") - ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") - ErrUsersQuotaReached = errors.New("Users quota reached") - ErrNoEmail = errors.New("Login provider didn't return an email address") - oauthLogger = log.New("oauth") -) +var oauthLogger = log.New("oauth") func GenStateString() string { rnd := make([]byte, 32) @@ -56,7 +48,7 @@ func OAuthLogin(ctx *m.ReqContext) { if errorParam != "" { errorDesc := ctx.Query("error_description") oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) - redirectWithError(ctx, ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) + redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) return } @@ -149,54 +141,43 @@ func OAuthLogin(ctx *m.ReqContext) { // validate that we got at least an email address if userInfo.Email == "" { - redirectWithError(ctx, ErrNoEmail) + redirectWithError(ctx, login.ErrNoEmail) return } // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { - redirectWithError(ctx, ErrEmailNotAllowed) + redirectWithError(ctx, login.ErrEmailNotAllowed) return } - userQuery := m.GetUserByEmailQuery{Email: userInfo.Email} - err = bus.Dispatch(&userQuery) + extUser := &m.ExternalUserInfo{ + AuthModule: "oauth_" + name, + AuthId: userInfo.Id, + Name: userInfo.Name, + Login: userInfo.Login, + Email: userInfo.Email, + OrgRoles: map[int64]m.RoleType{}, + } - // create account if missing - if err == m.ErrUserNotFound { - if !connect.IsSignupAllowed() { - redirectWithError(ctx, ErrSignUpNotAllowed) - return - } - limitReached, err := quota.QuotaReached(ctx, "user") - if err != nil { - ctx.Handle(500, "Failed to get user quota", err) - return - } - if limitReached { - redirectWithError(ctx, ErrUsersQuotaReached) - return - } - cmd := m.CreateUserCommand{ - Login: userInfo.Login, - Email: userInfo.Email, - Name: userInfo.Name, - Company: userInfo.Company, - DefaultOrgRole: userInfo.Role, - } + if userInfo.Role != "" { + extUser.OrgRoles[1] = m.RoleType(userInfo.Role) + } - if err = bus.Dispatch(&cmd); err != nil { - ctx.Handle(500, "Failed to create account", err) - return - } - - userQuery.Result = &cmd.Result - } else if err != nil { - ctx.Handle(500, "Unexpected error", err) + // add/update user in grafana + cmd := &m.UpsertUserCommand{ + ReqContext: ctx, + ExternalUser: extUser, + SignupAllowed: connect.IsSignupAllowed(), + } + err = bus.Dispatch(cmd) + if err != nil { + redirectWithError(ctx, err) + return } // login - loginUserWithUser(userQuery.Result, ctx) + loginUserWithUser(cmd.Result, ctx) metrics.M_Api_Login_OAuth.Inc() diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 5c06d652b70..c1b8ffe595e 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -75,7 +75,7 @@ func GetTestDataScenarios(c *m.ReqContext) Response { return JSON(200, &result) } -// Genereates a index out of range error +// Generates a index out of range error func GenerateError(c *m.ReqContext) Response { var array []string return JSON(200, array[20]) diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index d2413dfbb4c..a90b6425cb6 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -33,7 +33,7 @@ func ValidateOrgPlaylist(c *m.ReqContext) { return } - if len(items) == 0 { + if len(items) == 0 && c.Context.Req.Method != "DELETE" { c.JsonApiErr(404, "Playlist is empty", itemsErr) return } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 3e1a93abb3b..f4eb1140aa0 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -189,12 +189,6 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { } func (proxy *DataSourceProxy) validateRequest() error { - if proxy.ds.Type == m.DS_INFLUXDB { - if proxy.ctx.Query("db") != proxy.ds.Database { - return errors.New("Datasource is not configured to allow this database") - } - } - if !checkWhiteList(proxy.ctx, proxy.targetUrl.Host) { return errors.New("Target url is not a valid target") } diff --git a/pkg/api/static/static.go b/pkg/api/static/static.go index 7a61c85b4f3..2a35dd11fa6 100644 --- a/pkg/api/static/static.go +++ b/pkg/api/static/static.go @@ -48,7 +48,7 @@ type StaticOptions struct { // Expires defines which user-defined function to use for producing a HTTP Expires Header // https://developers.google.com/speed/docs/insights/LeverageBrowserCaching AddHeaders func(ctx *macaron.Context) - // FileSystem is the interface for supporting any implmentation of file system. + // FileSystem is the interface for supporting any implementation of file system. FileSystem http.FileSystem } diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index f40bc9c081b..9bdb73a5858 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -33,7 +33,7 @@ func validateInput(c CommandLine, pluginFolder string) error { fileInfo, err := os.Stat(pluginsDir) if err != nil { if err = os.MkdirAll(pluginsDir, os.ModePerm); err != nil { - return errors.New(fmt.Sprintf("pluginsDir (%s) is not a directory", pluginsDir)) + return fmt.Errorf("pluginsDir (%s) is not a writable directory", pluginsDir) } return nil } diff --git a/pkg/cmd/grafana-cli/commands/ls_command.go b/pkg/cmd/grafana-cli/commands/ls_command.go index 7dcecb9d725..30745ce3172 100644 --- a/pkg/cmd/grafana-cli/commands/ls_command.go +++ b/pkg/cmd/grafana-cli/commands/ls_command.go @@ -24,7 +24,7 @@ var validateLsCommand = func(pluginDir string) error { return fmt.Errorf("error: %s", err) } - if pluginDirInfo.IsDir() == false { + if !pluginDirInfo.IsDir() { return errors.New("plugin path is not a directory") } diff --git a/pkg/cmd/grafana-cli/commands/remove_command.go b/pkg/cmd/grafana-cli/commands/remove_command.go index d5ed73def05..e51929dc95c 100644 --- a/pkg/cmd/grafana-cli/commands/remove_command.go +++ b/pkg/cmd/grafana-cli/commands/remove_command.go @@ -3,12 +3,11 @@ package commands import ( "errors" "fmt" - m "github.com/grafana/grafana/pkg/cmd/grafana-cli/models" - services "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" "strings" + + services "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" ) -var getPluginss func(path string) []m.InstalledPlugin = services.GetLocalPlugins var removePlugin func(pluginPath, id string) error = services.RemoveInstalledPlugin func removeCommand(c CommandLine) error { diff --git a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go index 636292cce11..e01df2dab60 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go @@ -53,8 +53,7 @@ func upgradeAllCommand(c CommandLine) error { for _, p := range pluginsToUpgrade { logger.Infof("Updating %v \n", p.Id) - var err error - err = s.RemoveInstalledPlugin(pluginsDir, p.Id) + err := s.RemoveInstalledPlugin(pluginsDir, p.Id) if err != nil { return err } diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index d13e90d6a2f..e743d42022c 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path" + "runtime" "time" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" @@ -42,7 +43,7 @@ func Init(version string, skipTLSVerify bool) { } HttpClient = http.Client{ - Timeout: time.Duration(10 * time.Second), + Timeout: 10 * time.Second, Transport: tr, } } @@ -155,6 +156,8 @@ func sendRequest(repoUrl string, subPaths ...string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, u.String(), nil) req.Header.Set("grafana-version", grafanaVersion) + req.Header.Set("grafana-os", runtime.GOOS) + req.Header.Set("grafana-arch", runtime.GOARCH) req.Header.Set("User-Agent", "grafana "+grafanaVersion) if err != nil { diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index ab0e12f2d9f..777176f27da 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -33,7 +33,6 @@ import ( var version = "5.0.0" var commit = "NA" var buildstamp string -var build_date string var configFile = flag.String("config", "", "path to config file") var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory") diff --git a/pkg/components/apikeygen/apikeygen.go b/pkg/components/apikeygen/apikeygen.go index 310188a80ef..7824cf7667f 100644 --- a/pkg/components/apikeygen/apikeygen.go +++ b/pkg/components/apikeygen/apikeygen.go @@ -33,7 +33,7 @@ func New(orgId int64, name string) KeyGenResult { jsonString, _ := json.Marshal(jsonKey) - result.ClientSecret = base64.StdEncoding.EncodeToString([]byte(jsonString)) + result.ClientSecret = base64.StdEncoding.EncodeToString(jsonString) return result } @@ -44,7 +44,7 @@ func Decode(keyString string) (*ApiKeyJson, error) { } var keyObj ApiKeyJson - err = json.Unmarshal([]byte(jsonString), &keyObj) + err = json.Unmarshal(jsonString, &keyObj) if err != nil { return nil, ErrInvalidApiKey } diff --git a/pkg/components/dashdiffs/compare.go b/pkg/components/dashdiffs/compare.go index f5f2104cb92..d51011fbead 100644 --- a/pkg/components/dashdiffs/compare.go +++ b/pkg/components/dashdiffs/compare.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" diff "github.com/yudai/gojsondiff" deltaFormatter "github.com/yudai/gojsondiff/formatter" @@ -15,11 +14,8 @@ import ( var ( // ErrUnsupportedDiffType occurs when an invalid diff type is used. ErrUnsupportedDiffType = errors.New("dashdiff: unsupported diff type") - // ErrNilDiff occurs when two compared interfaces are identical. ErrNilDiff = errors.New("dashdiff: diff is nil") - - diffLogger = log.New("dashdiffs") ) type DiffType int diff --git a/pkg/components/dashdiffs/formatter_json.go b/pkg/components/dashdiffs/formatter_json.go index 3a9ddcc4ee3..488a345d492 100644 --- a/pkg/components/dashdiffs/formatter_json.go +++ b/pkg/components/dashdiffs/formatter_json.go @@ -22,7 +22,7 @@ const ( ) var ( - // changeTypeToSymbol is used for populating the terminating characer in + // changeTypeToSymbol is used for populating the terminating character in // the diff changeTypeToSymbol = map[ChangeType]string{ ChangeNil: "", diff --git a/pkg/components/dynmap/dynmap.go b/pkg/components/dynmap/dynmap.go index 797694845cd..2b86ce384eb 100644 --- a/pkg/components/dynmap/dynmap.go +++ b/pkg/components/dynmap/dynmap.go @@ -585,7 +585,6 @@ func (v *Value) Null() error { switch v.data.(type) { case nil: valid = v.exists // Valid only if j also exists, since other values could possibly also be nil - break } if valid { @@ -607,7 +606,6 @@ func (v *Value) Array() ([]*Value, error) { switch v.data.(type) { case []interface{}: valid = true - break } // Unsure if this is a good way to use slices, it's probably not @@ -638,7 +636,6 @@ func (v *Value) Number() (json.Number, error) { switch v.data.(type) { case json.Number: valid = true - break } if valid { @@ -687,7 +684,6 @@ func (v *Value) Boolean() (bool, error) { switch v.data.(type) { case bool: valid = true - break } if valid { @@ -709,7 +705,6 @@ func (v *Value) Object() (*Object, error) { switch v.data.(type) { case map[string]interface{}: valid = true - break } if valid { @@ -746,7 +741,6 @@ func (v *Value) ObjectArray() ([]*Object, error) { switch v.data.(type) { case []interface{}: valid = true - break } // Unsure if this is a good way to use slices, it's probably not @@ -782,7 +776,6 @@ func (v *Value) String() (string, error) { switch v.data.(type) { case string: valid = true - break } if valid { diff --git a/pkg/components/dynmap/dynmap_test.go b/pkg/components/dynmap/dynmap_test.go index cc002ea06e0..fa5f73c3719 100644 --- a/pkg/components/dynmap/dynmap_test.go +++ b/pkg/components/dynmap/dynmap_test.go @@ -21,7 +21,7 @@ func NewAssert(t *testing.T) *Assert { } func (assert *Assert) True(value bool, message string) { - if value == false { + if !value { log.Panicln("Assert: ", message) } } @@ -76,10 +76,10 @@ func TestFirst(t *testing.T) { assert.True(s == "fallback", "must get string return fallback") s, err = j.GetString("name") - assert.True(s == "anton" && err == nil, "name shoud match") + assert.True(s == "anton" && err == nil, "name should match") s, err = j.GetString("address", "street") - assert.True(s == "Street 42" && err == nil, "street shoud match") + assert.True(s == "Street 42" && err == nil, "street should match") //log.Println("s: ", s.String()) _, err = j.GetNumber("age") @@ -119,13 +119,13 @@ func TestFirst(t *testing.T) { assert.True(s == "" && err != nil, "nonexistent string fail") b, err := j.GetBoolean("true") - assert.True(b == true && err == nil, "bool true test") + assert.True(b && err == nil, "bool true test") b, err = j.GetBoolean("false") - assert.True(b == false && err == nil, "bool false test") + assert.True(!b && err == nil, "bool false test") b, err = j.GetBoolean("invalid_field") - assert.True(b == false && err != nil, "bool invalid test") + assert.True(!b && err != nil, "bool invalid test") list, err := j.GetValueArray("list") assert.True(list != nil && err == nil, "list should be an array") diff --git a/pkg/components/imguploader/azureblobuploader.go b/pkg/components/imguploader/azureblobuploader.go index 40d2de836be..3c0ac5b8884 100644 --- a/pkg/components/imguploader/azureblobuploader.go +++ b/pkg/components/imguploader/azureblobuploader.go @@ -225,7 +225,7 @@ func (a *Auth) SignRequest(req *http.Request) { ) decodedKey, _ := base64.StdEncoding.DecodeString(a.Key) - sha256 := hmac.New(sha256.New, []byte(decodedKey)) + sha256 := hmac.New(sha256.New, decodedKey) sha256.Write([]byte(strToSign)) signature := base64.StdEncoding.EncodeToString(sha256.Sum(nil)) diff --git a/pkg/components/null/float.go b/pkg/components/null/float.go index 1e78946e878..caf4d8b677c 100644 --- a/pkg/components/null/float.go +++ b/pkg/components/null/float.go @@ -50,7 +50,7 @@ func (f *Float) UnmarshalJSON(data []byte) error { } switch x := v.(type) { case float64: - f.Float64 = float64(x) + f.Float64 = x case map[string]interface{}: err = json.Unmarshal(data, &f.NullFloat64) case nil: diff --git a/pkg/log/file.go b/pkg/log/file.go index 721db1e55b3..d137adbf3de 100644 --- a/pkg/log/file.go +++ b/pkg/log/file.go @@ -99,10 +99,7 @@ func (w *FileLogWriter) StartLogger() error { return err } w.mw.SetFd(fd) - if err = w.initFd(); err != nil { - return err - } - return nil + return w.initFd() } func (w *FileLogWriter) docheck(size int) { diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 5527c7271d6..215a22cde33 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -8,23 +8,22 @@ import ( ) var ( - ErrInvalidCredentials = errors.New("Invalid Username or Password") - ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") + ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrNoEmail = errors.New("Login provider didn't return an email address") + ErrProviderDeniedRequest = errors.New("Login provider denied login request") + ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") + ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") + ErrUsersQuotaReached = errors.New("Users quota reached") + ErrGettingUserQuota = errors.New("Error getting user quota") ) -type LoginUserQuery struct { - Username string - Password string - User *m.User - IpAddress string -} - func Init() { bus.AddHandler("auth", AuthenticateUser) loadLdapConfig() } -func AuthenticateUser(query *LoginUserQuery) error { +func AuthenticateUser(query *m.LoginUserQuery) error { if err := validateLoginAttempts(query.Username); err != nil { return err } diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go index 59d3c8f2b33..932125c410e 100644 --- a/pkg/login/auth_test.go +++ b/pkg/login/auth_test.go @@ -151,7 +151,7 @@ func TestAuthenticateUser(t *testing.T) { } type authScenarioContext struct { - loginUserQuery *LoginUserQuery + loginUserQuery *m.LoginUserQuery grafanaLoginWasCalled bool ldapLoginWasCalled bool loginAttemptValidationWasCalled bool @@ -161,14 +161,14 @@ type authScenarioContext struct { type authScenarioFunc func(sc *authScenarioContext) func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { - loginUsingGrafanaDB = func(query *LoginUserQuery) error { + loginUsingGrafanaDB = func(query *m.LoginUserQuery) error { sc.grafanaLoginWasCalled = true return err } } func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) { - loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + loginUsingLdap = func(query *m.LoginUserQuery) (bool, error) { sc.ldapLoginWasCalled = true return enabled, err } @@ -182,7 +182,7 @@ func mockLoginAttemptValidation(err error, sc *authScenarioContext) { } func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { - saveInvalidLoginAttempt = func(query *LoginUserQuery) { + saveInvalidLoginAttempt = func(query *m.LoginUserQuery) { sc.saveInvalidLoginAttemptWasCalled = true } } @@ -195,7 +195,7 @@ func authScenario(desc string, fn authScenarioFunc) { origSaveInvalidLoginAttempt := saveInvalidLoginAttempt sc := &authScenarioContext{ - loginUserQuery: &LoginUserQuery{ + loginUserQuery: &m.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go index 2ea93979c7a..ca5e0a667ff 100644 --- a/pkg/login/brute_force_login_protection.go +++ b/pkg/login/brute_force_login_protection.go @@ -34,7 +34,7 @@ var validateLoginAttempts = func(username string) error { return nil } -var saveInvalidLoginAttempt = func(query *LoginUserQuery) { +var saveInvalidLoginAttempt = func(query *m.LoginUserQuery) { if setting.DisableBruteForceLoginProtection { return } diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go index 5375134ba88..aca100760c7 100644 --- a/pkg/login/brute_force_login_protection_test.go +++ b/pkg/login/brute_force_login_protection_test.go @@ -50,7 +50,7 @@ func TestLoginAttemptsValidation(t *testing.T) { return nil }) - saveInvalidLoginAttempt(&LoginUserQuery{ + saveInvalidLoginAttempt(&m.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", @@ -103,7 +103,7 @@ func TestLoginAttemptsValidation(t *testing.T) { return nil }) - saveInvalidLoginAttempt(&LoginUserQuery{ + saveInvalidLoginAttempt(&m.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go new file mode 100644 index 00000000000..e1d5e3e3b48 --- /dev/null +++ b/pkg/login/ext_user.go @@ -0,0 +1,184 @@ +package login + +import ( + "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/quota" +) + +func init() { + bus.AddHandler("auth", UpsertUser) +} + +func UpsertUser(cmd *m.UpsertUserCommand) error { + extUser := cmd.ExternalUser + + userQuery := &m.GetUserByAuthInfoQuery{ + AuthModule: extUser.AuthModule, + AuthId: extUser.AuthId, + UserId: extUser.UserId, + Email: extUser.Email, + Login: extUser.Login, + } + err := bus.Dispatch(userQuery) + if err != m.ErrUserNotFound && err != nil { + return err + } + + if err != nil { + if !cmd.SignupAllowed { + log.Warn("Not allowing %s login, user not found in internal user database and allow signup = false", extUser.AuthModule) + return ErrInvalidCredentials + } + + limitReached, err := quota.QuotaReached(cmd.ReqContext, "user") + if err != nil { + log.Warn("Error getting user quota", "err", err) + return ErrGettingUserQuota + } + if limitReached { + return ErrUsersQuotaReached + } + + cmd.Result, err = createUser(extUser) + if err != nil { + return err + } + + if extUser.AuthModule != "" && extUser.AuthId != "" { + cmd2 := &m.SetAuthInfoCommand{ + UserId: cmd.Result.Id, + AuthModule: extUser.AuthModule, + AuthId: extUser.AuthId, + } + if err := bus.Dispatch(cmd2); err != nil { + return err + } + } + + } else { + cmd.Result = userQuery.Result + + err = updateUser(cmd.Result, extUser) + if err != nil { + return err + } + } + + return syncOrgRoles(cmd.Result, extUser) +} + +func createUser(extUser *m.ExternalUserInfo) (*m.User, error) { + cmd := &m.CreateUserCommand{ + Login: extUser.Login, + Email: extUser.Email, + Name: extUser.Name, + SkipOrgSetup: len(extUser.OrgRoles) > 0, + } + if err := bus.Dispatch(cmd); err != nil { + return nil, err + } + + return &cmd.Result, nil +} + +func updateUser(user *m.User, extUser *m.ExternalUserInfo) error { + // sync user info + updateCmd := &m.UpdateUserCommand{ + UserId: user.Id, + } + + needsUpdate := false + if extUser.Login != "" && extUser.Login != user.Login { + updateCmd.Login = extUser.Login + user.Login = extUser.Login + needsUpdate = true + } + + if extUser.Email != "" && extUser.Email != user.Email { + updateCmd.Email = extUser.Email + user.Email = extUser.Email + needsUpdate = true + } + + if extUser.Name != "" && extUser.Name != user.Name { + updateCmd.Name = extUser.Name + user.Name = extUser.Name + needsUpdate = true + } + + if !needsUpdate { + return nil + } + + log.Debug("Syncing user info", "id", user.Id, "update", updateCmd) + return bus.Dispatch(updateCmd) +} + +func syncOrgRoles(user *m.User, extUser *m.ExternalUserInfo) error { + // don't sync org roles if none are specified + if len(extUser.OrgRoles) == 0 { + return nil + } + + orgsQuery := &m.GetUserOrgListQuery{UserId: user.Id} + if err := bus.Dispatch(orgsQuery); err != nil { + return err + } + + handledOrgIds := map[int64]bool{} + deleteOrgIds := []int64{} + + // update existing org roles + for _, org := range orgsQuery.Result { + handledOrgIds[org.OrgId] = true + + if extUser.OrgRoles[org.OrgId] == "" { + deleteOrgIds = append(deleteOrgIds, org.OrgId) + } else if extUser.OrgRoles[org.OrgId] != org.Role { + // update role + cmd := &m.UpdateOrgUserCommand{OrgId: org.OrgId, UserId: user.Id, Role: extUser.OrgRoles[org.OrgId]} + if err := bus.Dispatch(cmd); err != nil { + return err + } + } + } + + // add any new org roles + for orgId, orgRole := range extUser.OrgRoles { + if _, exists := handledOrgIds[orgId]; exists { + continue + } + + // add role + cmd := &m.AddOrgUserCommand{UserId: user.Id, Role: orgRole, OrgId: orgId} + err := bus.Dispatch(cmd) + if err != nil && err != m.ErrOrgNotFound { + return err + } + } + + // delete any removed org roles + for _, orgId := range deleteOrgIds { + cmd := &m.RemoveOrgUserCommand{OrgId: orgId, UserId: user.Id} + if err := bus.Dispatch(cmd); err != nil { + return err + } + } + + // update user's default org if needed + if _, ok := extUser.OrgRoles[user.OrgId]; !ok { + for orgId := range extUser.OrgRoles { + user.OrgId = orgId + break + } + + return bus.Dispatch(&m.SetUsingOrgCommand{ + UserId: user.Id, + OrgId: user.OrgId, + }) + } + + return nil +} diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go index 677ba776e4f..e8594fdd190 100644 --- a/pkg/login/grafana_login.go +++ b/pkg/login/grafana_login.go @@ -17,7 +17,7 @@ var validatePassword = func(providedPassword string, userPassword string, userSa return nil } -var loginUsingGrafanaDB = func(query *LoginUserQuery) error { +var loginUsingGrafanaDB = func(query *m.LoginUserQuery) error { userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} if err := bus.Dispatch(&userQuery); err != nil { diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go index 88e52224113..90422678fd2 100644 --- a/pkg/login/grafana_login_test.go +++ b/pkg/login/grafana_login_test.go @@ -66,7 +66,7 @@ func TestGrafanaLogin(t *testing.T) { } type grafanaLoginScenarioContext struct { - loginUserQuery *LoginUserQuery + loginUserQuery *m.LoginUserQuery validatePasswordCalled bool } @@ -77,7 +77,7 @@ func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) { origValidatePassword := validatePassword sc := &grafanaLoginScenarioContext{ - loginUserQuery: &LoginUserQuery{ + loginUserQuery: &m.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index be3babac02e..ccf44f77f23 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -24,10 +24,9 @@ type ILdapConn interface { } type ILdapAuther interface { - Login(query *LoginUserQuery) error - SyncSignedInUser(signedInUser *m.SignedInUser) error - GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) - SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error + Login(query *m.LoginUserQuery) error + SyncUser(query *m.LoginUserQuery) error + GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) } type ldapAuther struct { @@ -89,7 +88,8 @@ func (a *ldapAuther) Dial() error { return err } -func (a *ldapAuther) Login(query *LoginUserQuery) error { +func (a *ldapAuther) Login(query *m.LoginUserQuery) error { + // connect to ldap server if err := a.Dial(); err != nil { return err } @@ -101,206 +101,105 @@ func (a *ldapAuther) Login(query *LoginUserQuery) error { } // find user entry & attributes - if ldapUser, err := a.searchForUser(query.Username); err != nil { + ldapUser, err := a.searchForUser(query.Username) + if err != nil { return err - } else { - a.log.Debug("Ldap User found", "info", spew.Sdump(ldapUser)) + } - // check if a second user bind is needed - if a.requireSecondBind { - if err := a.secondBind(ldapUser, query.Password); err != nil { - return err - } - } + a.log.Debug("Ldap User found", "info", spew.Sdump(ldapUser)) - if grafanaUser, err := a.GetGrafanaUserFor(ldapUser); err != nil { + // check if a second user bind is needed + if a.requireSecondBind { + err = a.secondBind(ldapUser, query.Password) + if err != nil { return err - } else { - if syncErr := a.syncInfoAndOrgRoles(grafanaUser, ldapUser); syncErr != nil { - return syncErr - } - query.User = grafanaUser - return nil } } + + grafanaUser, err := a.GetGrafanaUserFor(query.ReqContext, ldapUser) + if err != nil { + return err + } + + query.User = grafanaUser + return nil } -func (a *ldapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { - grafanaUser := m.User{ - Id: signedInUser.UserId, - Login: signedInUser.Login, - Email: signedInUser.Email, - Name: signedInUser.Name, - } - - if err := a.Dial(); err != nil { +func (a *ldapAuther) SyncUser(query *m.LoginUserQuery) error { + // connect to ldap server + err := a.Dial() + if err != nil { return err } - defer a.conn.Close() - if err := a.serverBind(); err != nil { + + err = a.serverBind() + if err != nil { return err } - if ldapUser, err := a.searchForUser(signedInUser.Login); err != nil { + // find user entry & attributes + ldapUser, err := a.searchForUser(query.Username) + if err != nil { a.log.Error("Failed searching for user in ldap", "error", err) - return err - } else { - if err := a.syncInfoAndOrgRoles(&grafanaUser, ldapUser); err != nil { - return err + } + + a.log.Debug("Ldap User found", "info", spew.Sdump(ldapUser)) + + grafanaUser, err := a.GetGrafanaUserFor(query.ReqContext, ldapUser) + if err != nil { + return err + } + + query.User = grafanaUser + return nil +} + +func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) { + extUser := &m.ExternalUserInfo{ + AuthModule: "ldap", + AuthId: ldapUser.DN, + Name: fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName), + Login: ldapUser.Username, + Email: ldapUser.Email, + OrgRoles: map[int64]m.RoleType{}, + } + + for _, group := range a.server.LdapGroups { + // only use the first match for each org + if extUser.OrgRoles[group.OrgId] != "" { + continue } - a.log.Debug("Got Ldap User Info", "user", spew.Sdump(ldapUser)) + if ldapUser.isMemberOf(group.GroupDN) { + extUser.OrgRoles[group.OrgId] = group.OrgRole + } } - return nil -} - -// Sync info for ldap user and grafana user -func (a *ldapAuther) syncInfoAndOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { - // sync user details - if err := a.syncUserInfo(user, ldapUser); err != nil { - return err - } - // sync org roles - if err := a.SyncOrgRoles(user, ldapUser); err != nil { - return err - } - - return nil -} - -func (a *ldapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { // validate that the user has access // if there are no ldap group mappings access is true // otherwise a single group must match - access := len(a.server.LdapGroups) == 0 - for _, ldapGroup := range a.server.LdapGroups { - if ldapUser.isMemberOf(ldapGroup.GroupDN) { - access = true - break - } - } - - if !access { - a.log.Info("Ldap Auth: user does not belong in any of the specified ldap groups", "username", ldapUser.Username, "groups", ldapUser.MemberOf) + if len(a.server.LdapGroups) > 0 && len(extUser.OrgRoles) < 1 { + a.log.Info( + "Ldap Auth: user does not belong in any of the specified ldap groups", + "username", ldapUser.Username, + "groups", ldapUser.MemberOf) return nil, ErrInvalidCredentials } - // get user from grafana db - userQuery := m.GetUserByLoginQuery{LoginOrEmail: ldapUser.Username} - if err := bus.Dispatch(&userQuery); err != nil { - if err == m.ErrUserNotFound && setting.LdapAllowSignup { - return a.createGrafanaUser(ldapUser) - } else if err == m.ErrUserNotFound { - a.log.Warn("Not allowing LDAP login, user not found in internal user database, and ldap allow signup = false") - return nil, ErrInvalidCredentials - } else { - return nil, err - } + // add/update user in grafana + userQuery := &m.UpsertUserCommand{ + ReqContext: ctx, + ExternalUser: extUser, + SignupAllowed: setting.LdapAllowSignup, } - - return userQuery.Result, nil - -} -func (a *ldapAuther) createGrafanaUser(ldapUser *LdapUserInfo) (*m.User, error) { - cmd := m.CreateUserCommand{ - Login: ldapUser.Username, - Email: ldapUser.Email, - Name: fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName), - } - - if err := bus.Dispatch(&cmd); err != nil { + err := bus.Dispatch(userQuery) + if err != nil { return nil, err } - return &cmd.Result, nil -} - -func (a *ldapAuther) syncUserInfo(user *m.User, ldapUser *LdapUserInfo) error { - var name = fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName) - if user.Email == ldapUser.Email && user.Name == name { - return nil - } - - a.log.Debug("Syncing user info", "username", ldapUser.Username) - updateCmd := m.UpdateUserCommand{} - updateCmd.UserId = user.Id - updateCmd.Login = user.Login - updateCmd.Email = ldapUser.Email - updateCmd.Name = fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName) - return bus.Dispatch(&updateCmd) -} - -func (a *ldapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { - if len(a.server.LdapGroups) == 0 { - a.log.Warn("No group mappings defined") - return nil - } - - orgsQuery := m.GetUserOrgListQuery{UserId: user.Id} - if err := bus.Dispatch(&orgsQuery); err != nil { - return err - } - - handledOrgIds := map[int64]bool{} - - // update or remove org roles - for _, org := range orgsQuery.Result { - match := false - handledOrgIds[org.OrgId] = true - - for _, group := range a.server.LdapGroups { - if org.OrgId != group.OrgId { - continue - } - - if ldapUser.isMemberOf(group.GroupDN) { - match = true - if org.Role != group.OrgRole { - // update role - cmd := m.UpdateOrgUserCommand{OrgId: org.OrgId, UserId: user.Id, Role: group.OrgRole} - if err := bus.Dispatch(&cmd); err != nil { - return err - } - } - // ignore subsequent ldap group mapping matches - break - } - } - - // remove role if no mappings match - if !match { - cmd := m.RemoveOrgUserCommand{OrgId: org.OrgId, UserId: user.Id} - if err := bus.Dispatch(&cmd); err != nil { - return err - } - } - } - - // add missing org roles - for _, group := range a.server.LdapGroups { - if !ldapUser.isMemberOf(group.GroupDN) { - continue - } - - if _, exists := handledOrgIds[group.OrgId]; exists { - continue - } - - // add role - cmd := m.AddOrgUserCommand{UserId: user.Id, Role: group.OrgRole, OrgId: group.OrgId} - err := bus.Dispatch(&cmd) - if err != nil && err != m.ErrOrgNotFound { - return err - } - - // mark this group has handled so we do not process it again - handledOrgIds[group.OrgId] = true - } - - return nil + return userQuery.Result, nil } func (a *ldapAuther) serverBind() error { @@ -403,8 +302,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { // If we are using a POSIX LDAP schema it won't support memberOf, so we manually search the groups var groupSearchResult *ldap.SearchResult for _, groupSearchBase := range a.server.GroupSearchBaseDNs { - var filter_replace string - filter_replace = getLdapAttr(a.server.GroupSearchFilterUserAttribute, searchResult) + filter_replace := getLdapAttr(a.server.GroupSearchFilterUserAttribute, searchResult) if a.server.GroupSearchFilterUserAttribute == "" { filter_replace = getLdapAttr(a.server.Attr.Username, searchResult) } @@ -470,7 +368,3 @@ func getLdapAttrArray(name string, result *ldap.SearchResult) []string { } return []string{} } - -func createUserFromLdapInfo() error { - return nil -} diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index b74b69db036..5974e19d691 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -1,10 +1,11 @@ package login import ( + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -var loginUsingLdap = func(query *LoginUserQuery) (bool, error) { +var loginUsingLdap = func(query *m.LoginUserQuery) (bool, error) { if !setting.LdapEnabled { return false, nil } diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go index 6af125566e8..6067a063795 100644 --- a/pkg/login/ldap_login_test.go +++ b/pkg/login/ldap_login_test.go @@ -79,7 +79,7 @@ func TestLdapLogin(t *testing.T) { ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { sc.withLoginResult(false) - enabled, err := loginUsingLdap(&LoginUserQuery{ + enabled, err := loginUsingLdap(&m.LoginUserQuery{ Username: "user", Password: "pwd", }) @@ -117,7 +117,7 @@ type mockLdapAuther struct { loginCalled bool } -func (a *mockLdapAuther) Login(query *LoginUserQuery) error { +func (a *mockLdapAuther) Login(query *m.LoginUserQuery) error { a.loginCalled = true if !a.validLogin { @@ -127,20 +127,16 @@ func (a *mockLdapAuther) Login(query *LoginUserQuery) error { return nil } -func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { +func (a *mockLdapAuther) SyncUser(query *m.LoginUserQuery) error { return nil } -func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { +func (a *mockLdapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) { return nil, nil } -func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { - return nil -} - type ldapLoginScenarioContext struct { - loginUserQuery *LoginUserQuery + loginUserQuery *m.LoginUserQuery ldapAuthenticatorMock *mockLdapAuther } @@ -151,7 +147,7 @@ func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { origNewLdapAuthenticator := NewLdapAuthenticator sc := &ldapLoginScenarioContext{ - loginUserQuery: &LoginUserQuery{ + loginUserQuery: &m.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go index 8677bbeae42..6085fffb638 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/login/ldap_test.go @@ -18,7 +18,7 @@ func TestLdapAuther(t *testing.T) { ldapAuther := NewLdapAuthenticator(&LdapServerConf{ LdapGroups: []*LdapGroupToOrgRole{{}}, }) - _, err := ldapAuther.GetGrafanaUserFor(&LdapUserInfo{}) + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{}) So(err, ShouldEqual, ErrInvalidCredentials) }) @@ -34,7 +34,7 @@ func TestLdapAuther(t *testing.T) { sc.userQueryReturns(user1) - result, err := ldapAuther.GetGrafanaUserFor(&LdapUserInfo{}) + result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{}) So(err, ShouldBeNil) So(result, ShouldEqual, user1) }) @@ -48,7 +48,7 @@ func TestLdapAuther(t *testing.T) { sc.userQueryReturns(user1) - result, err := ldapAuther.GetGrafanaUserFor(&LdapUserInfo{MemberOf: []string{"cn=users"}}) + result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"cn=users"}}) So(err, ShouldBeNil) So(result, ShouldEqual, user1) }) @@ -64,7 +64,8 @@ func TestLdapAuther(t *testing.T) { sc.userQueryReturns(nil) - result, err := ldapAuther.GetGrafanaUserFor(&LdapUserInfo{ + result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + DN: "torkelo", Username: "torkelo", Email: "my@email.com", MemberOf: []string{"cn=editor"}, @@ -72,11 +73,6 @@ func TestLdapAuther(t *testing.T) { So(err, ShouldBeNil) - Convey("Should create new user", func() { - So(sc.createUserCmd.Login, ShouldEqual, "torkelo") - So(sc.createUserCmd.Email, ShouldEqual, "my@email.com") - }) - Convey("Should return new user", func() { So(result.Login, ShouldEqual, "torkelo") }) @@ -95,7 +91,7 @@ func TestLdapAuther(t *testing.T) { }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ MemberOf: []string{"cn=users"}, }) @@ -114,7 +110,7 @@ func TestLdapAuther(t *testing.T) { }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ MemberOf: []string{"cn=users"}, }) @@ -122,24 +118,29 @@ func TestLdapAuther(t *testing.T) { So(err, ShouldBeNil) So(sc.updateOrgUserCmd, ShouldNotBeNil) So(sc.updateOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN) + So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) }) }) ldapAutherScenario("given current org role is removed in ldap", func(sc *scenarioContext) { ldapAuther := NewLdapAuthenticator(&LdapServerConf{ LdapGroups: []*LdapGroupToOrgRole{ - {GroupDN: "cn=users", OrgId: 1, OrgRole: "Admin"}, + {GroupDN: "cn=users", OrgId: 2, OrgRole: "Admin"}, }, }) - sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ - MemberOf: []string{"cn=other"}, + sc.userOrgsQueryReturns([]*m.UserOrgDTO{ + {OrgId: 1, Role: m.ROLE_EDITOR}, + {OrgId: 2, Role: m.ROLE_EDITOR}, + }) + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + MemberOf: []string{"cn=users"}, }) Convey("Should remove org role", func() { So(err, ShouldBeNil) So(sc.removeOrgUserCmd, ShouldNotBeNil) + So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 2) }) }) @@ -152,7 +153,7 @@ func TestLdapAuther(t *testing.T) { }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ MemberOf: []string{"cn=users"}, }) @@ -160,6 +161,7 @@ func TestLdapAuther(t *testing.T) { So(err, ShouldBeNil) So(sc.removeOrgUserCmd, ShouldBeNil) So(sc.updateOrgUserCmd, ShouldNotBeNil) + So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) }) }) @@ -172,13 +174,14 @@ func TestLdapAuther(t *testing.T) { }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_ADMIN}}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ MemberOf: []string{"cn=admins"}, }) Convey("Should take first match, and ignore subsequent matches", func() { So(err, ShouldBeNil) So(sc.updateOrgUserCmd, ShouldBeNil) + So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) }) }) @@ -191,19 +194,20 @@ func TestLdapAuther(t *testing.T) { }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) - err := ldapAuther.SyncOrgRoles(&m.User{}, &LdapUserInfo{ + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ MemberOf: []string{"cn=admins"}, }) Convey("Should take first match, and ignore subsequent matches", func() { So(err, ShouldBeNil) So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN) + So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) }) }) }) - Convey("When calling SyncSignedInUser", t, func() { + Convey("When calling SyncUser", t, func() { mockLdapConnection := &mockLdapConn{} ldapAuther := NewLdapAuthenticator( @@ -243,17 +247,20 @@ func TestLdapAuther(t *testing.T) { ldapAutherScenario("When ldapUser found call syncInfo and orgRoles", func(sc *scenarioContext) { // arrange - signedInUser := &m.SignedInUser{ - Email: "roel@test.net", - UserId: 1, - Name: "Roel Gerrits", - Login: "roelgerrits", + query := &m.LoginUserQuery{ + Username: "roelgerrits", } + sc.userQueryReturns(&m.User{ + Id: 1, + Email: "roel@test.net", + Name: "Roel Gerrits", + Login: "roelgerrits", + }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) // act - syncErrResult := ldapAuther.SyncSignedInUser(signedInUser) + syncErrResult := ldapAuther.SyncUser(query) // assert So(dialCalled, ShouldBeTrue) @@ -299,6 +306,19 @@ func ldapAutherScenario(desc string, fn scenarioFunc) { sc := &scenarioContext{} + bus.AddHandler("test", UpsertUser) + + bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error { + sc.getUserByAuthInfoQuery = cmd + sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login} + return nil + }) + + bus.AddHandler("test", func(cmd *m.GetUserOrgListQuery) error { + sc.getUserOrgListQuery = cmd + return nil + }) + bus.AddHandler("test", func(cmd *m.CreateUserCommand) error { sc.createUserCmd = cmd sc.createUserCmd.Result = m.User{Login: cmd.Login} @@ -325,20 +345,28 @@ func ldapAutherScenario(desc string, fn scenarioFunc) { return nil }) + bus.AddHandler("test", func(cmd *m.SetUsingOrgCommand) error { + sc.setUsingOrgCmd = cmd + return nil + }) + fn(sc) }) } type scenarioContext struct { - createUserCmd *m.CreateUserCommand - addOrgUserCmd *m.AddOrgUserCommand - updateOrgUserCmd *m.UpdateOrgUserCommand - removeOrgUserCmd *m.RemoveOrgUserCommand - updateUserCmd *m.UpdateUserCommand + getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery + getUserOrgListQuery *m.GetUserOrgListQuery + createUserCmd *m.CreateUserCommand + addOrgUserCmd *m.AddOrgUserCommand + updateOrgUserCmd *m.UpdateOrgUserCommand + removeOrgUserCmd *m.RemoveOrgUserCommand + updateUserCmd *m.UpdateUserCommand + setUsingOrgCmd *m.SetUsingOrgCommand } func (sc *scenarioContext) userQueryReturns(user *m.User) { - bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { + bus.AddHandler("test", func(query *m.GetUserByAuthInfoQuery) error { if user == nil { return m.ErrUserNotFound } else { @@ -346,6 +374,9 @@ func (sc *scenarioContext) userQueryReturns(user *m.User) { return nil } }) + bus.AddHandler("test", func(query *m.SetAuthInfoCommand) error { + return nil + }) } func (sc *scenarioContext) userOrgsQueryReturns(orgs []*m.UserOrgDTO) { diff --git a/pkg/metrics/graphitebridge/graphite.go b/pkg/metrics/graphitebridge/graphite.go index 68fb544fc7c..670636cedce 100644 --- a/pkg/metrics/graphitebridge/graphite.go +++ b/pkg/metrics/graphitebridge/graphite.go @@ -295,11 +295,7 @@ func writeMetric(buf *bufio.Writer, m model.Metric, mf *dto.MetricFamily) error } } - if err = addExtentionConventionForRollups(buf, mf, m); err != nil { - return err - } - - return nil + return addExtentionConventionForRollups(buf, mf, m) } func addExtentionConventionForRollups(buf *bufio.Writer, mf *dto.MetricFamily, m model.Metric) error { diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 4d4a11d0faa..e3640378f7e 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -54,6 +54,7 @@ var ( M_Alerting_Active_Alerts prometheus.Gauge M_StatTotal_Dashboards prometheus.Gauge M_StatTotal_Users prometheus.Gauge + M_StatActive_Users prometheus.Gauge M_StatTotal_Orgs prometheus.Gauge M_StatTotal_Playlists prometheus.Gauge M_Grafana_Version *prometheus.GaugeVec @@ -253,6 +254,12 @@ func init() { Namespace: exporterName, }) + M_StatActive_Users = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_active_users", + Help: "number of active users", + Namespace: exporterName, + }) + M_StatTotal_Orgs = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "stat_total_orgs", Help: "total amount of orgs", @@ -270,7 +277,6 @@ func init() { Help: "Information about the Grafana", Namespace: exporterName, }, []string{"version"}) - } func initMetricVars(settings *MetricSettings) { @@ -305,6 +311,7 @@ func initMetricVars(settings *MetricSettings) { M_Alerting_Active_Alerts, M_StatTotal_Dashboards, M_StatTotal_Users, + M_StatActive_Users, M_StatTotal_Orgs, M_StatTotal_Playlists, M_Grafana_Version) @@ -315,35 +322,36 @@ func initMetricVars(settings *MetricSettings) { func instrumentationLoop(settings *MetricSettings) chan struct{} { M_Instance_Start.Inc() + // set the total stats gauges before we publishing metrics + updateTotalStats() + onceEveryDayTick := time.NewTicker(time.Hour * 24) - secondTicker := time.NewTicker(time.Second * time.Duration(settings.IntervalSeconds)) + everyMinuteTicker := time.NewTicker(time.Minute) + defer onceEveryDayTick.Stop() + defer everyMinuteTicker.Stop() for { select { case <-onceEveryDayTick.C: sendUsageStats() - case <-secondTicker.C: + case <-everyMinuteTicker.C: updateTotalStats() } } } -var metricPublishCounter int64 = 0 - func updateTotalStats() { - metricPublishCounter++ - if metricPublishCounter == 1 || metricPublishCounter%10 == 0 { - statsQuery := models.GetSystemStatsQuery{} - if err := bus.Dispatch(&statsQuery); err != nil { - metricsLogger.Error("Failed to get system stats", "error", err) - return - } - - M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards)) - M_StatTotal_Users.Set(float64(statsQuery.Result.Users)) - M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists)) - M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs)) + statsQuery := models.GetSystemStatsQuery{} + if err := bus.Dispatch(&statsQuery); err != nil { + metricsLogger.Error("Failed to get system stats", "error", err) + return } + + M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards)) + M_StatTotal_Users.Set(float64(statsQuery.Result.Users)) + M_StatActive_Users.Set(float64(statsQuery.Result.ActiveUsers)) + M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists)) + M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs)) } func sendUsageStats() { @@ -403,6 +411,6 @@ func sendUsageStats() { out, _ := json.MarshalIndent(report, "", " ") data := bytes.NewBuffer(out) - client := http.Client{Timeout: time.Duration(5 * time.Second)} + client := http.Client{Timeout: 5 * time.Second} go client.Post("https://stats.grafana.org/grafana-usage-report", "application/json", data) } diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index adf0b7b53d5..36b059e4ae7 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -3,6 +3,7 @@ package middleware import ( "fmt" "net" + "net/mail" "strings" "time" @@ -14,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" + func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { if !setting.AuthProxyEnabled { return false @@ -30,40 +33,104 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { return true } - query := getSignedInUserQueryForProxyAuth(proxyHeaderValue) - 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 { - 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.Context); err != nil { log.Error(3, "Failed to start session", err) return false } + query := &m.GetSignedInUserQuery{OrgId: orgID} + + // if this session has already been authenticated by authProxy just load the user + sessProxyValue := ctx.Session.Get(AUTH_PROXY_SESSION_VAR) + if sessProxyValue != nil && sessProxyValue.(string) == proxyHeaderValue && getRequestUserId(ctx) > 0 { + // if we're using ldap, sync user periodically + if setting.LdapEnabled { + syncQuery := &m.LoginUserQuery{ + ReqContext: ctx, + Username: proxyHeaderValue, + } + + if err := syncGrafanaUserWithLdapUser(syncQuery); err != nil { + if err == login.ErrInvalidCredentials { + ctx.Handle(500, "Unable to authenticate user", err) + return false + } + + ctx.Handle(500, "Failed to sync user", err) + return false + } + } + + query.UserId = getRequestUserId(ctx) + // if we're using ldap, pass authproxy login name to ldap user sync + } else if setting.LdapEnabled { + ctx.Session.Delete(session.SESS_KEY_LASTLDAPSYNC) + + syncQuery := &m.LoginUserQuery{ + ReqContext: ctx, + Username: proxyHeaderValue, + } + + if err := syncGrafanaUserWithLdapUser(syncQuery); err != nil { + if err == login.ErrInvalidCredentials { + ctx.Handle(500, "Unable to authenticate user", err) + return false + } + + ctx.Handle(500, "Failed to sync user", err) + return false + } + + if syncQuery.User == nil { + ctx.Handle(500, "Failed to sync user", nil) + return false + } + + query.UserId = syncQuery.User.Id + // no ldap, just use the info we have + } else { + extUser := &m.ExternalUserInfo{ + AuthModule: "authproxy", + AuthId: proxyHeaderValue, + } + + if setting.AuthProxyHeaderProperty == "username" { + extUser.Login = proxyHeaderValue + + // only set Email if it can be parsed as an email address + emailAddr, emailErr := mail.ParseAddress(proxyHeaderValue) + if emailErr == nil { + extUser.Email = emailAddr.Address + } + } else if setting.AuthProxyHeaderProperty == "email" { + extUser.Email = proxyHeaderValue + extUser.Login = proxyHeaderValue + } else { + ctx.Handle(500, "Auth proxy header property invalid", nil) + return true + } + + // add/update user in grafana + cmd := &m.UpsertUserCommand{ + ReqContext: ctx, + ExternalUser: extUser, + SignupAllowed: setting.AuthProxyAutoSignUp, + } + err := bus.Dispatch(cmd) + if err != nil { + ctx.Handle(500, "Failed to login as user specified in auth proxy header", err) + return true + } + + query.UserId = cmd.Result.Id + } + + if err := bus.Dispatch(query); err != nil { + ctx.Handle(500, "Failed to find user", err) + return true + } + // Make sure that we cannot share a session between different users! if getRequestUserId(ctx) > 0 && getRequestUserId(ctx) != query.Result.UserId { // remove session @@ -77,16 +144,7 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { } } - // When ldap is enabled, sync userinfo and org roles - if err := syncGrafanaUserWithLdapUser(ctx, query); err != nil { - if err == login.ErrInvalidCredentials { - ctx.Handle(500, "Unable to authenticate user", err) - return false - } - - ctx.Handle(500, "Failed to sync user", err) - return false - } + ctx.Session.Set(AUTH_PROXY_SESSION_VAR, proxyHeaderValue) ctx.SignedInUser = query.Result ctx.IsSignedIn = true @@ -95,29 +153,29 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { return true } -var syncGrafanaUserWithLdapUser = func(ctx *m.ReqContext, query *m.GetSignedInUserQuery) error { - if !setting.LdapEnabled { - return nil - } - +var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error { expireEpoch := time.Now().Add(time.Duration(-setting.AuthProxyLdapSyncTtl) * time.Minute).Unix() var lastLdapSync int64 - if lastLdapSyncInSession := ctx.Session.Get(session.SESS_KEY_LASTLDAPSYNC); lastLdapSyncInSession != nil { + if lastLdapSyncInSession := query.ReqContext.Session.Get(session.SESS_KEY_LASTLDAPSYNC); lastLdapSyncInSession != nil { lastLdapSync = lastLdapSyncInSession.(int64) } if lastLdapSync < expireEpoch { ldapCfg := login.LdapCfg + if len(ldapCfg.Servers) < 1 { + return fmt.Errorf("No LDAP servers available") + } + for _, server := range ldapCfg.Servers { author := login.NewLdapAuthenticator(server) - if err := author.SyncSignedInUser(query.Result); err != nil { + if err := author.SyncUser(query); err != nil { return err } } - ctx.Session.Set(session.SESS_KEY_LASTLDAPSYNC, time.Now().Unix()) + query.ReqContext.Session.Set(session.SESS_KEY_LASTLDAPSYNC, time.Now().Unix()) } return nil @@ -143,29 +201,3 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP) } - -func getSignedInUserQueryForProxyAuth(headerVal string) *m.GetSignedInUserQuery { - query := m.GetSignedInUserQuery{} - if setting.AuthProxyHeaderProperty == "username" { - query.Login = headerVal - } else if setting.AuthProxyHeaderProperty == "email" { - query.Email = headerVal - } else { - panic("Auth proxy header property invalid") - } - return &query -} - -func getCreateUserCommandForProxyAuth(headerVal string) *m.CreateUserCommand { - cmd := m.CreateUserCommand{} - if setting.AuthProxyHeaderProperty == "username" { - cmd.Login = headerVal - cmd.Email = headerVal - } else if setting.AuthProxyHeaderProperty == "email" { - cmd.Email = headerVal - cmd.Login = headerVal - } else { - panic("Auth proxy header property invalid") - } - return &cmd -} diff --git a/pkg/middleware/auth_proxy_test.go b/pkg/middleware/auth_proxy_test.go index b3c011bd870..47ed2f71a79 100644 --- a/pkg/middleware/auth_proxy_test.go +++ b/pkg/middleware/auth_proxy_test.go @@ -26,57 +26,71 @@ func TestAuthProxyWithLdapEnabled(t *testing.T) { return &mockLdapAuther } - signedInUser := m.SignedInUser{} - query := m.GetSignedInUserQuery{Result: &signedInUser} - - Convey("When session variable lastLdapSync not set, call syncSignedInUser and set lastLdapSync", func() { + Convey("When user logs in, call SyncUser", func() { // arrange - sess := mockSession{} + sess := newMockSession() ctx := m.ReqContext{Session: &sess} So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeNil) // act - syncGrafanaUserWithLdapUser(&ctx, &query) + syncGrafanaUserWithLdapUser(&m.LoginUserQuery{ + ReqContext: &ctx, + Username: "test", + }) // assert - So(mockLdapAuther.syncSignedInUserCalled, ShouldBeTrue) + So(mockLdapAuther.syncUserCalled, ShouldBeTrue) 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 - sess := mockSession{} + sess := newMockSession() ctx := m.ReqContext{Session: &sess} now := time.Now().Unix() sess.Set(session.SESS_KEY_LASTLDAPSYNC, now) + sess.Set(AUTH_PROXY_SESSION_VAR, "test") // act - syncGrafanaUserWithLdapUser(&ctx, &query) + syncGrafanaUserWithLdapUser(&m.LoginUserQuery{ + ReqContext: &ctx, + Username: "test", + }) // assert So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldEqual, now) - So(mockLdapAuther.syncSignedInUserCalled, ShouldBeFalse) + So(mockLdapAuther.syncUserCalled, ShouldBeFalse) }) Convey("When lastldapsync is expired, session variable should be updated", func() { // arrange - sess := mockSession{} + sess := newMockSession() ctx := m.ReqContext{Session: &sess} expiredTime := time.Now().Add(time.Duration(-120) * time.Minute).Unix() sess.Set(session.SESS_KEY_LASTLDAPSYNC, expiredTime) + sess.Set(AUTH_PROXY_SESSION_VAR, "test") // act - syncGrafanaUserWithLdapUser(&ctx, &query) + syncGrafanaUserWithLdapUser(&m.LoginUserQuery{ + ReqContext: &ctx, + Username: "test", + }) // assert So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, expiredTime) - So(mockLdapAuther.syncSignedInUserCalled, ShouldBeTrue) + So(mockLdapAuther.syncUserCalled, ShouldBeTrue) }) }) } type mockSession struct { - value interface{} + value map[interface{}]interface{} +} + +func newMockSession() mockSession { + session := mockSession{} + session.value = make(map[interface{}]interface{}) + return session } func (s *mockSession) Start(c *macaron.Context) error { @@ -84,15 +98,16 @@ func (s *mockSession) Start(c *macaron.Context) error { } func (s *mockSession) Set(k interface{}, v interface{}) error { - s.value = v + s.value[k] = v return nil } func (s *mockSession) Get(k interface{}) interface{} { - return s.value + return s.value[k] } func (s *mockSession) Delete(k interface{}) interface{} { + delete(s.value, k) return nil } @@ -113,21 +128,18 @@ func (s *mockSession) RegenerateId(c *macaron.Context) error { } type mockLdapAuthenticator struct { - syncSignedInUserCalled bool + syncUserCalled bool } -func (a *mockLdapAuthenticator) Login(query *login.LoginUserQuery) error { +func (a *mockLdapAuthenticator) Login(query *m.LoginUserQuery) error { return nil } -func (a *mockLdapAuthenticator) SyncSignedInUser(signedInUser *m.SignedInUser) error { - a.syncSignedInUserCalled = true +func (a *mockLdapAuthenticator) SyncUser(query *m.LoginUserQuery) error { + a.syncUserCalled = true return nil } -func (a *mockLdapAuthenticator) GetGrafanaUserFor(ldapUser *login.LdapUserInfo) (*m.User, error) { +func (a *mockLdapAuthenticator) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *login.LdapUserInfo) (*m.User, error) { return nil, nil } -func (a *mockLdapAuthenticator) SyncOrgRoles(user *m.User, ldapUser *login.LdapUserInfo) error { - return nil -} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index b5b244d5bff..93db49ed880 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "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" @@ -165,7 +164,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { user := loginQuery.Result - loginUserQuery := l.LoginUserQuery{Username: username, Password: password, User: user} + loginUserQuery := m.LoginUserQuery{Username: username, Password: password, User: user} if err := bus.Dispatch(&loginUserQuery); err != nil { ctx.JsonApiErr(401, "Invalid username or password", err) return true diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index f80a30de02f..072cb793d3c 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -9,7 +9,6 @@ import ( 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" @@ -72,7 +71,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - bus.AddHandler("test", func(loginUserQuery *l.LoginUserQuery) error { + bus.AddHandler("test", func(loginUserQuery *m.LoginUserQuery) error { return nil }) @@ -177,12 +176,18 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyEnabled = true setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderProperty = "username" + setting.LdapEnabled = false bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} return nil }) + bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { + cmd.Result = &m.User{Id: 12} + return nil + }) + sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.exec() @@ -199,6 +204,7 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyHeaderName = "X-WEBAUTH-USER" setting.AuthProxyHeaderProperty = "username" setting.AuthProxyAutoSignUp = true + setting.LdapEnabled = false bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { if query.UserId > 0 { @@ -209,8 +215,8 @@ func TestMiddlewareContext(t *testing.T) { } }) - bus.AddHandler("test", func(cmd *m.CreateUserCommand) error { - cmd.Result = m.User{Id: 33} + bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { + cmd.Result = &m.User{Id: 33} return nil }) @@ -271,6 +277,11 @@ func TestMiddlewareContext(t *testing.T) { return nil }) + bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { + cmd.Result = &m.User{Id: 33} + return nil + }) + sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.req.RemoteAddr = "[2001::23]:12345" @@ -289,6 +300,11 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyHeaderProperty = "username" setting.AuthProxyWhitelist = "" + bus.AddHandler("test", func(query *m.UpsertUserCommand) error { + query.Result = &m.User{Id: 32} + return nil + }) + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 4, UserId: 32} return nil @@ -319,11 +335,17 @@ func TestMiddlewareContext(t *testing.T) { setting.LdapEnabled = true called := false - syncGrafanaUserWithLdapUser = func(ctx *m.ReqContext, query *m.GetSignedInUserQuery) error { + syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error { called = true + query.User = &m.User{Id: 32} return nil } + bus.AddHandler("test", func(query *m.UpsertUserCommand) error { + query.Result = &m.User{Id: 32} + return nil + }) + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 4, UserId: 32} return nil diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index ec289387aa4..456bc91354e 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -35,7 +35,7 @@ var ( slash = []byte("/") ) -// stack returns a nicely formated stack frame, skipping skip frames +// stack returns a nicely formatted stack frame, skipping skip frames func stack(skip int) []byte { buf := new(bytes.Buffer) // the returned data // As we loop, we open files and read them. These variables record the currently diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 5b91b2a70b4..4ef8061486b 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -56,7 +56,10 @@ type DashboardAclInfoDTO struct { UserId int64 `json:"userId"` UserLogin string `json:"userLogin"` UserEmail string `json:"userEmail"` + UserAvatarUrl string `json:"userAvatarUrl"` TeamId int64 `json:"teamId"` + TeamEmail string `json:"teamEmail"` + TeamAvatarUrl string `json:"teamAvatarUrl"` Team string `json:"team"` Role *RoleType `json:"role,omitempty"` Permission PermissionType `json:"permission"` diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 8cd2b01811c..eb44c1bc582 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -13,26 +13,27 @@ import ( // Typed errors var ( - 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" + 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") + ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard") + RootFolderName = "General" ) type UpdatePluginDashboardError struct { @@ -157,7 +158,7 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { return dash } -// GetDashboardModel turns the command into the savable model +// GetDashboardModel turns the command into the saveable model func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash := NewDashboardFromJson(cmd.Dashboard) userId := cmd.UserId @@ -224,6 +225,10 @@ func GetFolderUrl(folderUid string, slug string) string { return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug) } +type ValidateDashboardBeforeSaveResult struct { + IsParentFolderChanged bool +} + // // COMMANDS // @@ -268,6 +273,7 @@ type ValidateDashboardBeforeSaveCommand struct { OrgId int64 Dashboard *Dashboard Overwrite bool + Result *ValidateDashboardBeforeSaveResult } // @@ -317,6 +323,12 @@ type GetDashboardSlugByIdQuery struct { Result string } +type IsDashboardProvisionedQuery struct { + DashboardId int64 + + Result bool +} + type GetProvisionedDashboardDataQuery struct { Name string diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index b4a4e7f8a4d..66ba66e4d39 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -33,7 +33,7 @@ func (ds *DataSource) GetHttpClient() (*http.Client, error) { } return &http.Client{ - Timeout: time.Duration(30 * time.Second), + Timeout: 30 * time.Second, Transport: transport, }, nil } diff --git a/pkg/models/folders.go b/pkg/models/folders.go index c61620a11fc..0c876edcfd7 100644 --- a/pkg/models/folders.go +++ b/pkg/models/folders.go @@ -32,7 +32,7 @@ type Folder struct { HasAcl bool } -// GetDashboardModel turns the command into the savable model +// GetDashboardModel turns the command into the saveable model func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard { dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title)) dashFolder.OrgId = orgId diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index ca32cc50060..9231d18cfd6 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -48,9 +48,9 @@ func (r *RoleType) UnmarshalJSON(data []byte) error { *r = RoleType(str) - if (*r).IsValid() == false { + if !(*r).IsValid() { if (*r) != "" { - return errors.New(fmt.Sprintf("JSON validation error: invalid role value: %s", *r)) + return fmt.Errorf("JSON validation error: invalid role value: %s", *r) } *r = ROLE_VIEWER diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go new file mode 100644 index 00000000000..0ecd144d52c --- /dev/null +++ b/pkg/models/user_auth.go @@ -0,0 +1,72 @@ +package models + +import ( + "time" +) + +type UserAuth struct { + Id int64 + UserId int64 + AuthModule string + AuthId string + Created time.Time +} + +type ExternalUserInfo struct { + AuthModule string + AuthId string + UserId int64 + Email string + Login string + Name string + OrgRoles map[int64]RoleType +} + +// --------------------- +// COMMANDS + +type UpsertUserCommand struct { + ReqContext *ReqContext + ExternalUser *ExternalUserInfo + SignupAllowed bool + + Result *User +} + +type SetAuthInfoCommand struct { + AuthModule string + AuthId string + UserId int64 +} + +type DeleteAuthInfoCommand struct { + UserAuth *UserAuth +} + +// ---------------------- +// QUERIES + +type LoginUserQuery struct { + ReqContext *ReqContext + Username string + Password string + User *User + IpAddress string +} + +type GetUserByAuthInfoQuery struct { + AuthModule string + AuthId string + UserId int64 + Email string + Login string + + Result *User +} + +type GetAuthInfoQuery struct { + AuthModule string + AuthId string + + Result *UserAuth +} diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go index 834e8238e3a..7ada6fb6b03 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go @@ -74,7 +74,7 @@ func TestMappingRowValue(t *testing.T) { boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true}) haveBool, ok := boolRowValue.(bool) - if !ok || haveBool != true { + if !ok || !haveBool { t.Fatalf("Expected true, was %v", haveBool) } diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 541b37c8a8a..9677c21ef04 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -69,7 +69,7 @@ func (pb *PluginBase) registerPlugin(pluginDir string) error { for _, include := range pb.Includes { if include.Role == "" { - include.Role = m.RoleType(m.ROLE_VIEWER) + include.Role = m.ROLE_VIEWER } } diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 4d3ccb4502b..00329b4a8a1 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -12,7 +12,7 @@ import ( func TestPluginScans(t *testing.T) { - Convey("When scaning for plugins", t, func() { + Convey("When scanning for plugins", t, func() { setting.StaticRootPath, _ = filepath.Abs("../../public/") setting.Cfg = ini.Empty() err := initPlugins(context.Background()) diff --git a/pkg/plugins/queries.go b/pkg/plugins/queries.go index 5ae1825a88f..5bd412d2cc9 100644 --- a/pkg/plugins/queries.go +++ b/pkg/plugins/queries.go @@ -37,7 +37,7 @@ func GetPluginSettings(orgId int64) (map[string]*m.PluginSettingInfoDTO, error) // if it's included in app check app settings if pluginDef.IncludedInAppId != "" { - // app componets are by default disabled + // app components are by default disabled opt.Enabled = false if appSettings, ok := pluginMap[pluginDef.IncludedInAppId]; ok { diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index 68ccdeaf840..946d215b1c2 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -13,7 +13,7 @@ import ( ) var ( - httpClient http.Client = http.Client{Timeout: time.Duration(10 * time.Second)} + httpClient http.Client = http.Client{Timeout: 10 * time.Second} ) type GrafanaNetPlugin struct { diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go index 1b8fb952f65..dfc058940cf 100644 --- a/pkg/services/alerting/conditions/evaluator.go +++ b/pkg/services/alerting/conditions/evaluator.go @@ -20,7 +20,7 @@ type AlertEvaluator interface { type NoValueEvaluator struct{} func (e *NoValueEvaluator) Eval(reducedValue null.Float) bool { - return reducedValue.Valid == false + return !reducedValue.Valid } type ThresholdEvaluator struct { @@ -45,7 +45,7 @@ func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvalua } func (e *ThresholdEvaluator) Eval(reducedValue null.Float) bool { - if reducedValue.Valid == false { + if !reducedValue.Valid { return false } @@ -88,7 +88,7 @@ func newRangedEvaluator(typ string, model *simplejson.Json) (*RangedEvaluator, e } func (e *RangedEvaluator) Eval(reducedValue null.Float) bool { - if reducedValue.Valid == false { + if !reducedValue.Valid { return false } diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index d499c5e8532..7d1a276c42e 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -53,7 +53,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio reducedValue := c.Reducer.Reduce(series) evalMatch := c.Evaluator.Eval(reducedValue) - if reducedValue.Valid == false { + if !reducedValue.Valid { emptySerieCount++ } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index a6f97333d1b..0945a2a5330 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -15,8 +15,8 @@ import ( ) type Engine struct { - execQueue chan *Job - clock clock.Clock + execQueue chan *Job + //clock clock.Clock ticker *Ticker scheduler Scheduler evalHandler EvalHandler diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go index 64f954c6dd5..63108bbb9aa 100644 --- a/pkg/services/alerting/engine_test.go +++ b/pkg/services/alerting/engine_test.go @@ -10,7 +10,7 @@ import ( ) type FakeEvalHandler struct { - SuccessCallID int // 0 means never sucess + SuccessCallID int // 0 means never success CallNb int } @@ -87,7 +87,7 @@ func TestEngineProcessJob(t *testing.T) { Convey("Should trigger as many retries as needed", func() { - Convey("never sucess -> max retries number", func() { + Convey("never success -> max retries number", func() { expectedAttempts := alertMaxAttempts evalHandler := NewFakeEvalHandler(0) engine.evalHandler = evalHandler @@ -96,7 +96,7 @@ func TestEngineProcessJob(t *testing.T) { So(evalHandler.CallNb, ShouldEqual, expectedAttempts) }) - Convey("always sucess -> never retry", func() { + Convey("always success -> never retry", func() { expectedAttempts := 1 evalHandler := NewFakeEvalHandler(1) engine.evalHandler = evalHandler @@ -105,7 +105,7 @@ func TestEngineProcessJob(t *testing.T) { So(evalHandler.CallNb, ShouldEqual, expectedAttempts) }) - Convey("some errors before sucess -> some retries", func() { + Convey("some errors before success -> some retries", func() { expectedAttempts := int(math.Ceil(float64(alertMaxAttempts) / 2)) evalHandler := NewFakeEvalHandler(expectedAttempts) engine.evalHandler = evalHandler diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index edd872b8fce..e1c1bfacb2e 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -104,7 +104,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, // backward compatibility check, can be removed later enabled, hasEnabled := jsonAlert.CheckGet("enabled") - if hasEnabled && enabled.MustBool() == false { + if hasEnabled && !enabled.MustBool() { continue } diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index e32b9d34f91..14eacef5831 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -72,7 +72,10 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error { this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name) } - body, _ := bodyJSON.MarshalJSON() + body, err := bodyJSON.MarshalJSON() + if err != nil { + return err + } cmd := &m.SendWebhookSync{ Url: this.Url, diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index f1f63d42a04..58e1b7bd71e 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -111,7 +111,7 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { } message := "" - if evalContext.Rule.State != models.AlertStateOK { //dont add message when going back to alert state ok. + if evalContext.Rule.State != models.AlertStateOK { //don't add message when going back to alert state ok. message += " " + evalContext.Rule.Message } diff --git a/pkg/services/alerting/notifiers/line.go b/pkg/services/alerting/notifiers/line.go index 4fbaa2d543e..4814662f3a9 100644 --- a/pkg/services/alerting/notifiers/line.go +++ b/pkg/services/alerting/notifiers/line.go @@ -90,7 +90,7 @@ func (this *LineNotifier) createAlert(evalContext *alerting.EvalContext) error { } if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { - this.log.Error("Failed to send notification to LINE", "error", err, "body", string(body)) + this.log.Error("Failed to send notification to LINE", "error", err, "body", body) return err } diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index e051a71740a..a8139b62726 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -129,7 +129,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { } message := this.Mention - if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. + if evalContext.Rule.State != m.AlertStateOK { //don't add message when going back to alert state ok. message += " " + evalContext.Rule.Message } image_url := "" diff --git a/pkg/services/alerting/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go index 9a9e93dbc47..7f62340d0e1 100644 --- a/pkg/services/alerting/notifiers/teams.go +++ b/pkg/services/alerting/notifiers/teams.go @@ -13,7 +13,7 @@ func init() { alerting.RegisterNotifier(&alerting.NotifierPlugin{ Type: "teams", Name: "Microsoft Teams", - Description: "Sends notifications using Incomming Webhook connector to Microsoft Teams", + Description: "Sends notifications using Incoming Webhook connector to Microsoft Teams", Factory: NewTeamsNotifier, OptionsTemplate: `

Teams settings

@@ -76,7 +76,7 @@ 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. + if evalContext.Rule.State != m.AlertStateOK { //don't add message when going back to alert state ok. message += " " + evalContext.Rule.Message } else { message += " " // summary must not be empty diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 5cbdad60906..0b8efe808d5 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -219,7 +219,7 @@ func appendIfPossible(message string, extra string, sizeLimit int) string { func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { var cmd *m.SendWebhookSync - if evalContext.ImagePublicUrl == "" && this.UploadImage == true { + if evalContext.ImagePublicUrl == "" && this.UploadImage { cmd = this.buildMessage(evalContext, true) } else { cmd = this.buildMessage(evalContext, false) diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go index 05be787dced..98c8d884ad0 100644 --- a/pkg/services/alerting/notifiers/telegram_test.go +++ b/pkg/services/alerting/notifiers/telegram_test.go @@ -100,7 +100,7 @@ func TestTelegramNotifier(t *testing.T) { 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() { + Convey("Metrics should be skipped if they don't 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 ", diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go index 45f0c65d4fb..627159c286b 100644 --- a/pkg/services/alerting/reader.go +++ b/pkg/services/alerting/reader.go @@ -16,7 +16,7 @@ type RuleReader interface { type DefaultRuleReader struct { sync.RWMutex - serverID string + //serverID string serverPosition int clusterSize int log log.Logger diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 8f9deb758a6..0c92fc32110 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -56,7 +56,7 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { if err := bus.Dispatch(cmd); err != nil { if err == m.ErrCannotChangeStateOnPausedAlert { - handler.log.Error("Cannot change state on alert thats pause", "error", err) + handler.log.Error("Cannot change state on alert that's paused", "error", err) return err } @@ -77,7 +77,7 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { Text: "", NewState: string(evalContext.Rule.State), PrevState: string(evalContext.PrevAlertState), - Epoch: time.Now().Unix(), + Epoch: time.Now().UnixNano() / int64(time.Millisecond), Data: annotationData, } diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index bdf53798e34..027ff96d6c0 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -55,8 +55,8 @@ func (e ValidationError) Error() string { } var ( - ValueFormatRegex = regexp.MustCompile("^\\d+") - UnitFormatRegex = regexp.MustCompile("\\w{1}$") + ValueFormatRegex = regexp.MustCompile(`^\d+`) + UnitFormatRegex = regexp.MustCompile(`\w{1}$`) ) var unitMultiplier = map[string]int{ diff --git a/pkg/services/alerting/scheduler.go b/pkg/services/alerting/scheduler.go index 151f802ec15..b7555ae8d89 100644 --- a/pkg/services/alerting/scheduler.go +++ b/pkg/services/alerting/scheduler.go @@ -15,7 +15,7 @@ type SchedulerImpl struct { func NewScheduler() Scheduler { return &SchedulerImpl{ - jobs: make(map[int64]*Job, 0), + jobs: make(map[int64]*Job), log: log.New("alerting.scheduler"), } } @@ -23,7 +23,7 @@ func NewScheduler() Scheduler { func (s *SchedulerImpl) Update(rules []*Rule) { s.log.Debug("Scheduling update", "ruleCount", len(rules)) - jobs := make(map[int64]*Job, 0) + jobs := make(map[int64]*Job) for i, rule := range rules { var job *Job @@ -58,7 +58,7 @@ func (s *SchedulerImpl) Tick(tickTime time.Time, execQueue chan *Job) { if job.OffsetWait && now%job.Offset == 0 { job.OffsetWait = false - s.enque(job, execQueue) + s.enqueue(job, execQueue) continue } @@ -66,13 +66,13 @@ func (s *SchedulerImpl) Tick(tickTime time.Time, execQueue chan *Job) { if job.Offset > 0 { job.OffsetWait = true } else { - s.enque(job, execQueue) + s.enqueue(job, execQueue) } } } } -func (s *SchedulerImpl) enque(job *Job, execQueue chan *Job) { +func (s *SchedulerImpl) enqueue(job *Job, execQueue chan *Job) { s.log.Debug("Scheduler: Putting job on to exec queue", "name", job.Rule.Name, "id", job.Rule.Id) execQueue <- job } diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index a6cd7a33318..5cebb3d2df9 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -13,6 +13,7 @@ type ItemQuery struct { OrgId int64 `json:"orgId"` From int64 `json:"from"` To int64 `json:"to"` + UserId int64 `json:"userId"` AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` PanelId int64 `json:"panelId"` @@ -63,6 +64,8 @@ type Item struct { PrevState string `json:"prevState"` NewState string `json:"newState"` Epoch int64 `json:"epoch"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` Tags []string `json:"tags"` Data *simplejson.Json `json:"data"` @@ -80,6 +83,8 @@ type ItemDTO struct { UserId int64 `json:"userId"` NewState string `json:"newState"` PrevState string `json:"prevState"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` Time int64 `json:"time"` Text string `json:"text"` RegionId int64 `json:"regionId"` diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 02a6ffc8330..278421e6be7 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -57,7 +57,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod return cmd.Result, nil } -func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) { +func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) { dash := dto.Dashboard dash.Title = strings.TrimSpace(dash.Title) @@ -103,6 +103,29 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, return nil, err } + if validateBeforeSaveCmd.Result.IsParentFolderChanged { + folderGuardian := guardian.New(dash.FolderId, dto.OrgId, dto.User) + if canSave, err := folderGuardian.CanSave(); err != nil || !canSave { + if err != nil { + return nil, err + } + return nil, models.ErrDashboardUpdateAccessDenied + } + } + + if validateProvisionedDashboard { + isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dash.Id} + err := bus.Dispatch(isDashboardProvisioned) + + if err != nil { + return nil, err + } + + if isDashboardProvisioned.Result { + return nil, models.ErrDashboardCannotSaveProvisionedDashboard + } + } + guard := guardian.New(dash.GetDashboardIdForSavePermissionCheck(), dto.OrgId, dto.User) if canSave, err := guard.CanSave(); err != nil || !canSave { if err != nil { @@ -148,7 +171,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto, true) + cmd, err := dr.buildSaveDashboardCommand(dto, true, false) if err != nil { return nil, err } @@ -178,7 +201,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto, false) + cmd, err := dr.buildSaveDashboardCommand(dto, false, false) if err != nil { return nil, err } @@ -197,7 +220,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash } func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto, true) + cmd, err := dr.buildSaveDashboardCommand(dto, true, true) if err != nil { return nil, err } @@ -216,7 +239,7 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da } func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto, false) + cmd, err := dr.buildSaveDashboardCommand(dto, false, true) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index 965b10655b3..f9d487f625c 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -14,7 +14,9 @@ import ( func TestDashboardService(t *testing.T) { Convey("Dashboard service tests", t, func() { - service := dashboardServiceImpl{} + bus.ClearBusHandlers() + + service := &dashboardServiceImpl{} origNewDashboardGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) @@ -51,6 +53,12 @@ func TestDashboardService(t *testing.T) { }) bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return nil + }) + + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + cmd.Result = false return nil }) @@ -72,12 +80,42 @@ func TestDashboardService(t *testing.T) { dto.Dashboard.SetUid(tc.Uid) dto.User = &models.SignedInUser{} - _, err := service.buildSaveDashboardCommand(dto, true) + _, err := service.buildSaveDashboardCommand(dto, true, false) So(err, ShouldEqual, tc.Error) } }) + Convey("Should return validation error if dashboard is provisioned", func() { + provisioningValidated := false + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + provisioningValidated = true + cmd.Result = true + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return nil + }) + + dto.Dashboard = models.NewDashboard("Dash") + dto.Dashboard.SetId(3) + dto.User = &models.SignedInUser{UserId: 1} + _, err := service.SaveDashboard(dto) + So(provisioningValidated, ShouldBeTrue) + So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard) + }) + Convey("Should return validation error if alert data is invalid", func() { + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + cmd.Result = false + return nil + }) + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { return errors.New("error") }) @@ -88,6 +126,80 @@ func TestDashboardService(t *testing.T) { }) }) + Convey("Save provisioned dashboard validation", func() { + dto := &SaveDashboardDTO{} + + Convey("Should not return validation error if dashboard is provisioned", func() { + provisioningValidated := false + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + provisioningValidated = true + cmd.Result = true + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + dto.Dashboard = models.NewDashboard("Dash") + dto.Dashboard.SetId(3) + dto.User = &models.SignedInUser{UserId: 1} + _, err := service.SaveProvisionedDashboard(dto, nil) + So(err, ShouldBeNil) + So(provisioningValidated, ShouldBeFalse) + }) + }) + + Convey("Import dashboard validation", func() { + dto := &SaveDashboardDTO{} + + Convey("Should return validation error if dashboard is provisioned", func() { + provisioningValidated := false + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + provisioningValidated = true + cmd.Result = true + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + dto.Dashboard = models.NewDashboard("Dash") + dto.Dashboard.SetId(3) + dto.User = &models.SignedInUser{UserId: 1} + _, err := service.ImportDashboard(dto) + So(provisioningValidated, ShouldBeTrue) + So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard) + }) + }) + Reset(func() { guardian.New = origNewDashboardGuardian }) diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go index ae92952056e..b521b0e5213 100644 --- a/pkg/services/dashboards/folder_service.go +++ b/pkg/services/dashboards/folder_service.go @@ -104,7 +104,7 @@ func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) er User: dr.user, } - saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false) if err != nil { return toFolderError(err) } @@ -141,7 +141,7 @@ func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.Upd Overwrite: cmd.Overwrite, } - saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false) if err != nil { return toFolderError(err) } diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/folder_service_test.go index 6c0413d1878..4c9cecd3352 100644 --- a/pkg/services/dashboards/folder_service_test.go +++ b/pkg/services/dashboards/folder_service_test.go @@ -32,6 +32,7 @@ func TestFolderService(t *testing.T) { }) bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} return models.ErrDashboardUpdateAccessDenied }) @@ -92,6 +93,7 @@ func TestFolderService(t *testing.T) { }) bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} return nil }) @@ -108,11 +110,19 @@ func TestFolderService(t *testing.T) { return nil }) + provisioningValidated := false + + bus.AddHandler("test", func(query *models.IsDashboardProvisionedQuery) error { + provisioningValidated = true + return nil + }) + Convey("When creating folder should not return access denied error", func() { err := service.CreateFolder(&models.CreateFolderCommand{ Title: "Folder", }) So(err, ShouldBeNil) + So(provisioningValidated, ShouldBeFalse) }) Convey("When updating folder should not return access denied error", func() { @@ -121,6 +131,7 @@ func TestFolderService(t *testing.T) { Title: "Folder", }) So(err, ShouldBeNil) + So(provisioningValidated, ShouldBeFalse) }) Convey("When deleting folder by uid should not return access denied error", func() { diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 811b38cac86..700f22d8d26 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -113,7 +113,7 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D return false, err } - // evalute team rules + // evaluate team rules for _, p := range acl { for _, ug := range teams { if ug.Id == p.TeamId && p.Permission >= permission { @@ -173,7 +173,7 @@ func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.Permiss return true, nil } - return g.checkAcl(permission, acl) + return g.checkAcl(permission, existingPermissions) } // GetAcl returns dashboard acl diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go index bb7e6bd1a72..9de12c60fea 100644 --- a/pkg/services/guardian/guardian_test.go +++ b/pkg/services/guardian/guardian_test.go @@ -2,710 +2,663 @@ package guardian import ( "fmt" + "runtime" "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) - }) +var ( + orgID = int64(1) + defaultDashboardID = int64(-1) + dashboardID = int64(1) + parentFolderID = int64(2) + childDashboardID = int64(3) + userID = int64(1) + otherUserID = int64(2) + teamID = int64(1) + otherTeamID = int64(2) + adminRole = m.ROLE_ADMIN + editorRole = m.ROLE_EDITOR + viewerRole = m.ROLE_VIEWER +) + +func TestGuardianAdmin(t *testing.T) { + Convey("Guardian admin org role tests", t, func() { + orgRoleScenario("Given user has admin org role", t, m.ROLE_ADMIN, func(sc *scenarioContext) { + // dashboard has default permissions + sc.defaultPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + + // dashboard has user with permission + sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_VIEW, FULL_ACCESS) + + // dashboard has team with permission + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_VIEW, FULL_ACCESS) + + // dashboard has editor role with permission + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_VIEW, FULL_ACCESS) + + // dashboard has viewer role with permission + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_VIEW, FULL_ACCESS) + + // parent folder has user with permission + sc.parentFolderPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_VIEW, FULL_ACCESS) + + // parent folder has team with permission + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_VIEW, FULL_ACCESS) + + // parent folder has editor role with permission + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_VIEW, FULL_ACCESS) + + // parent folder has viweer role with permission + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_VIEW, FULL_ACCESS) }) }) } -type scenarioContext struct { - g DashboardGuardian +func TestGuardianEditor(t *testing.T) { + Convey("Guardian editor org role tests", t, func() { + orgRoleScenario("Given user has editor org role", t, m.ROLE_EDITOR, func(sc *scenarioContext) { + // dashboard has user with permission + sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_VIEW, CAN_VIEW) + + // dashboard has team with permission + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_VIEW, CAN_VIEW) + + // dashboard has editor role with permission + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // dashboard has viewer role with permission + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_ADMIN, NO_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_EDIT, NO_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_VIEW, NO_ACCESS) + + // parent folder has user with permission + sc.parentFolderPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has team with permission + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has editor role with permission + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has viweer role with permission + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_ADMIN, NO_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_EDIT, NO_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_VIEW, NO_ACCESS) + }) + }) } -type scenarioFunc func(c *scenarioContext) +func TestGuardianViewer(t *testing.T) { + Convey("Guardian viewer org role tests", t, func() { + orgRoleScenario("Given user has viewer org role", t, m.ROLE_VIEWER, func(sc *scenarioContext) { + // dashboard has user with permission + sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(USER, m.PERMISSION_VIEW, VIEWER_ACCESS) -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, + // dashboard has team with permission + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(TEAM, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // dashboard has editor role with permission + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_ADMIN, NO_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_EDIT, NO_ACCESS) + sc.dashboardPermissionScenario(EDITOR, m.PERMISSION_VIEW, NO_ACCESS) + + // dashboard has viewer role with permission + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(VIEWER, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has user with permission + sc.parentFolderPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(USER, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has team with permission + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(TEAM, m.PERMISSION_VIEW, VIEWER_ACCESS) + + // parent folder has editor role with permission + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_ADMIN, NO_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_EDIT, NO_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, m.PERMISSION_VIEW, NO_ACCESS) + + // parent folder has viweer role with permission + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_VIEW, VIEWER_ACCESS) + }) + }) +} + +func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, permission m.PermissionType, flag permissionFlags) { + _, callerFile, callerLine, _ := runtime.Caller(1) + sc.callerFile = callerFile + sc.callerLine = callerLine + existingPermissions := []*m.DashboardAclInfoDTO{ + toDto(newEditorRolePermission(defaultDashboardID, m.PERMISSION_EDIT)), + toDto(newViewerRolePermission(defaultDashboardID, m.PERMISSION_VIEW)), } - Convey(desc, func() { - fn(sc) + permissionScenario("and existing permissions is the default permissions (everyone with editor role can edit, everyone with viewer role can view)", dashboardID, sc, existingPermissions, func(sc *scenarioContext) { + sc.expectedFlags = flag + sc.verifyExpectedPermissionsFlags() + sc.verifyDuplicatePermissionsShouldNotBeAllowed() + sc.verifyUpdateDashboardPermissionsShouldBeAllowed(pt) + sc.verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt) }) } -func permissionScenario(desc string, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) { - bus.ClearBusHandlers() +func (sc *scenarioContext) dashboardPermissionScenario(pt permissionType, permission m.PermissionType, flag permissionFlags) { + _, callerFile, callerLine, _ := runtime.Caller(1) + sc.callerFile = callerFile + sc.callerLine = callerLine + var existingPermissions []*m.DashboardAclInfoDTO - bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { - query.Result = permissions - return nil + switch pt { + case USER: + existingPermissions = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: dashboardID, UserId: userID, Permission: permission}} + case TEAM: + existingPermissions = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: dashboardID, TeamId: teamID, Permission: permission}} + case EDITOR: + existingPermissions = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: dashboardID, Role: &editorRole, Permission: permission}} + case VIEWER: + existingPermissions = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: dashboardID, Role: &viewerRole, Permission: permission}} + } + + permissionScenario(fmt.Sprintf("and %s has permission to %s dashboard", pt.String(), permission.String()), dashboardID, sc, existingPermissions, func(sc *scenarioContext) { + sc.expectedFlags = flag + sc.verifyExpectedPermissionsFlags() + sc.verifyDuplicatePermissionsShouldNotBeAllowed() + sc.verifyUpdateDashboardPermissionsShouldBeAllowed(pt) + sc.verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt) }) +} - teams := []*m.Team{} +func (sc *scenarioContext) parentFolderPermissionScenario(pt permissionType, permission m.PermissionType, flag permissionFlags) { + _, callerFile, callerLine, _ := runtime.Caller(1) + sc.callerFile = callerFile + sc.callerLine = callerLine + var folderPermissionList []*m.DashboardAclInfoDTO - for _, p := range permissions { - if p.TeamId > 0 { - teams = append(teams, &m.Team{Id: p.TeamId}) + switch pt { + case USER: + folderPermissionList = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: parentFolderID, UserId: userID, Permission: permission}} + case TEAM: + folderPermissionList = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: parentFolderID, TeamId: teamID, Permission: permission}} + case EDITOR: + folderPermissionList = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: parentFolderID, Role: &editorRole, Permission: permission}} + case VIEWER: + folderPermissionList = []*m.DashboardAclInfoDTO{{OrgId: orgID, DashboardId: parentFolderID, Role: &viewerRole, Permission: permission}} + } + + permissionScenario(fmt.Sprintf("and parent folder has %s with permission to %s", pt.String(), permission.String()), childDashboardID, sc, folderPermissionList, func(sc *scenarioContext) { + sc.expectedFlags = flag + sc.verifyExpectedPermissionsFlags() + sc.verifyDuplicatePermissionsShouldNotBeAllowed() + sc.verifyUpdateChildDashboardPermissionsShouldBeAllowed(pt, permission) + sc.verifyUpdateChildDashboardPermissionsShouldNotBeAllowed(pt, permission) + sc.verifyUpdateChildDashboardPermissionsWithOverrideShouldBeAllowed(pt, permission) + sc.verifyUpdateChildDashboardPermissionsWithOverrideShouldNotBeAllowed(pt, permission) + }) +} + +func (sc *scenarioContext) verifyExpectedPermissionsFlags() { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + + tc := fmt.Sprintf("should have permissions to %s", sc.expectedFlags.String()) + Convey(tc, func() { + var actualFlag permissionFlags + + if canAdmin { + actualFlag |= CAN_ADMIN } - } - bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = teams - return nil - }) + if canEdit { + actualFlag |= CAN_EDIT + } - Convey(desc, func() { - fn(sc) + if canSave { + actualFlag |= CAN_SAVE + } + + if canView { + actualFlag |= CAN_VIEW + } + + if actualFlag.noAccess() { + actualFlag = NO_ACCESS + } + + if sc.expectedFlags&actualFlag != sc.expectedFlags { + sc.reportFailure(tc, sc.expectedFlags.String(), actualFlag.String()) + } + + sc.reportSuccess() }) } -func userWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { - p := []*m.DashboardAclInfoDTO{ - {OrgId: 1, DashboardId: 1, UserId: 1, Permission: permission}, +func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { + if !sc.expectedFlags.canAdmin() { + return } - permissionScenario(fmt.Sprintf("and user has permission to %s item", permission), sc, p, fn) + + tc := "When updating dashboard permissions with duplicate permission for user should not be allowed" + Convey(tc, func() { + p := []*m.DashboardAcl{ + newDefaultUserPermission(dashboardID, m.PERMISSION_VIEW), + newDefaultUserPermission(dashboardID, m.PERMISSION_ADMIN), + } + sc.updatePermissions = p + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + + if err != ErrGuardianPermissionExists { + sc.reportFailure(tc, ErrGuardianPermissionExists, err) + } + sc.reportSuccess() + }) + + tc = "When updating dashboard permissions with duplicate permission for team should not be allowed" + Convey(tc, func() { + p := []*m.DashboardAcl{ + newDefaultTeamPermission(dashboardID, m.PERMISSION_VIEW), + newDefaultTeamPermission(dashboardID, m.PERMISSION_ADMIN), + } + sc.updatePermissions = p + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + + if err != ErrGuardianPermissionExists { + sc.reportFailure(tc, ErrGuardianPermissionExists, err) + } + sc.reportSuccess() + }) + + tc = "When updating dashboard permissions with duplicate permission for editor role should not be allowed" + Convey(tc, func() { + p := []*m.DashboardAcl{ + newEditorRolePermission(dashboardID, m.PERMISSION_VIEW), + newEditorRolePermission(dashboardID, m.PERMISSION_ADMIN), + } + sc.updatePermissions = p + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + + if err != ErrGuardianPermissionExists { + sc.reportFailure(tc, ErrGuardianPermissionExists, err) + } + sc.reportSuccess() + }) + + tc = "When updating dashboard permissions with duplicate permission for viewer role should not be allowed" + Convey(tc, func() { + p := []*m.DashboardAcl{ + newViewerRolePermission(dashboardID, m.PERMISSION_VIEW), + newViewerRolePermission(dashboardID, m.PERMISSION_ADMIN), + } + sc.updatePermissions = p + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + + if err != ErrGuardianPermissionExists { + sc.reportFailure(tc, ErrGuardianPermissionExists, err) + } + sc.reportSuccess() + }) + + tc = "When updating dashboard permissions with duplicate permission for admin role should not be allowed" + Convey(tc, func() { + p := []*m.DashboardAcl{ + newAdminRolePermission(dashboardID, m.PERMISSION_ADMIN), + } + sc.updatePermissions = p + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + + if err != ErrGuardianPermissionExists { + sc.reportFailure(tc, ErrGuardianPermissionExists, err) + } + sc.reportSuccess() + }) } -func teamWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { - p := []*m.DashboardAclInfoDTO{ - {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: permission}, +func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldBeAllowed(pt permissionType) { + if !sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + tc := fmt.Sprintf("When updating dashboard permissions with %s permissions should be allowed", p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{} + switch pt { + case USER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(dashboardID, p), + newViewerRolePermission(dashboardID, p), + newCustomUserPermission(dashboardID, otherUserID, p), + newDefaultTeamPermission(dashboardID, p), + } + case TEAM: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(dashboardID, p), + newViewerRolePermission(dashboardID, p), + newDefaultUserPermission(dashboardID, p), + newCustomTeamPermission(dashboardID, otherTeamID, p), + } + case EDITOR, VIEWER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(dashboardID, p), + newViewerRolePermission(dashboardID, p), + newDefaultUserPermission(dashboardID, p), + newDefaultTeamPermission(dashboardID, p), + } + } + + sc.updatePermissions = permissionList + ok, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != nil { + sc.reportFailure(tc, nil, err) + } + if !ok { + sc.reportFailure(tc, false, true) + } + sc.reportSuccess() + }) } - 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}, +func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt permissionType) { + if sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + tc := fmt.Sprintf("When updating dashboard permissions with %s permissions should NOT be allowed", p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{ + newEditorRolePermission(dashboardID, p), + newViewerRolePermission(dashboardID, p), + } + switch pt { + case USER: + permissionList = append(permissionList, []*m.DashboardAcl{ + newCustomUserPermission(dashboardID, otherUserID, p), + newDefaultTeamPermission(dashboardID, p), + }...) + case TEAM: + permissionList = append(permissionList, []*m.DashboardAcl{ + newDefaultUserPermission(dashboardID, p), + newCustomTeamPermission(dashboardID, otherTeamID, p), + }...) + } + + sc.updatePermissions = permissionList + ok, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != nil { + sc.reportFailure(tc, nil, err) + } + if ok { + sc.reportFailure(tc, true, false) + } + sc.reportSuccess() + }) + } +} + +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldBeAllowed(pt permissionType, parentFolderPermission m.PermissionType) { + if !sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + tc := fmt.Sprintf("When updating child dashboard permissions with %s permissions should be allowed", p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{} + switch pt { + case USER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newViewerRolePermission(childDashboardID, p), + newCustomUserPermission(childDashboardID, otherUserID, p), + newDefaultTeamPermission(childDashboardID, p), + } + case TEAM: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newViewerRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newCustomTeamPermission(childDashboardID, otherTeamID, p), + } + case EDITOR: + permissionList = []*m.DashboardAcl{ + newViewerRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newDefaultTeamPermission(childDashboardID, p), + } + + // permission to update is higher than parent folder permission + if p > parentFolderPermission { + permissionList = append(permissionList, newEditorRolePermission(childDashboardID, p)) + } + case VIEWER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newDefaultTeamPermission(childDashboardID, p), + } + + // permission to update is higher than parent folder permission + if p > parentFolderPermission { + permissionList = append(permissionList, newViewerRolePermission(childDashboardID, p)) + } + } + + sc.updatePermissions = permissionList + ok, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != nil { + sc.reportFailure(tc, nil, err) + } + if !ok { + sc.reportFailure(tc, false, true) + } + sc.reportSuccess() + }) + } +} + +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldNotBeAllowed(pt permissionType, parentFolderPermission m.PermissionType) { + if sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + tc := fmt.Sprintf("When updating child dashboard permissions with %s permissions should NOT be allowed", p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{} + switch pt { + case USER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newViewerRolePermission(childDashboardID, p), + newCustomUserPermission(childDashboardID, otherUserID, p), + newDefaultTeamPermission(childDashboardID, p), + } + case TEAM: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newViewerRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newCustomTeamPermission(childDashboardID, otherTeamID, p), + } + case EDITOR: + permissionList = []*m.DashboardAcl{ + newViewerRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newDefaultTeamPermission(childDashboardID, p), + } + + // perminssion to update is higher than parent folder permission + if p > parentFolderPermission { + permissionList = append(permissionList, newEditorRolePermission(childDashboardID, p)) + } + case VIEWER: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + newDefaultUserPermission(childDashboardID, p), + newDefaultTeamPermission(childDashboardID, p), + } + + // perminssion to update is higher than parent folder permission + if p > parentFolderPermission { + permissionList = append(permissionList, newViewerRolePermission(childDashboardID, p)) + } + } + + sc.updatePermissions = permissionList + ok, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != nil { + sc.reportFailure(tc, nil, err) + } + if ok { + sc.reportFailure(tc, true, false) + } + sc.reportSuccess() + }) + } +} + +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldBeAllowed(pt permissionType, parentFolderPermission m.PermissionType) { + if !sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + // perminssion to update is higher tban parent folder permission + if p > parentFolderPermission { + continue + } + + tc := fmt.Sprintf("When updating child dashboard permissions overriding parent %s permission with %s permission should NOT be allowed", pt.String(), p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{} + switch pt { + case USER: + permissionList = []*m.DashboardAcl{ + newDefaultUserPermission(childDashboardID, p), + } + case TEAM: + permissionList = []*m.DashboardAcl{ + newDefaultTeamPermission(childDashboardID, p), + } + case EDITOR: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + } + case VIEWER: + permissionList = []*m.DashboardAcl{ + newViewerRolePermission(childDashboardID, p), + } + } + + sc.updatePermissions = permissionList + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != ErrGuardianOverride { + sc.reportFailure(tc, ErrGuardianOverride, err) + } + sc.reportSuccess() + }) + } +} + +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldNotBeAllowed(pt permissionType, parentFolderPermission m.PermissionType) { + if !sc.expectedFlags.canAdmin() { + return + } + + for _, p := range []m.PermissionType{m.PERMISSION_ADMIN, m.PERMISSION_EDIT, m.PERMISSION_VIEW} { + // perminssion to update is lower than/equal parent folder permission + if p <= parentFolderPermission { + continue + } + + tc := fmt.Sprintf("When updating child dashboard permissions overriding parent %s permission with %s permission should be allowed", pt.String(), p.String()) + + Convey(tc, func() { + permissionList := []*m.DashboardAcl{} + switch pt { + case USER: + permissionList = []*m.DashboardAcl{ + newDefaultUserPermission(childDashboardID, p), + } + case TEAM: + permissionList = []*m.DashboardAcl{ + newDefaultTeamPermission(childDashboardID, p), + } + case EDITOR: + permissionList = []*m.DashboardAcl{ + newEditorRolePermission(childDashboardID, p), + } + case VIEWER: + permissionList = []*m.DashboardAcl{ + newViewerRolePermission(childDashboardID, p), + } + } + + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + sc.updatePermissions = permissionList + ok, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, permissionList) + + if err != nil { + sc.reportFailure(tc, nil, err) + } + if !ok { + sc.reportFailure(tc, false, true) + } + sc.reportSuccess() + }) } - permissionScenario(fmt.Sprintf("and everyone with %s role can %s item", role, permission), sc, p, fn) } diff --git a/pkg/services/guardian/guardian_util_test.go b/pkg/services/guardian/guardian_util_test.go new file mode 100644 index 00000000000..b065c4194ad --- /dev/null +++ b/pkg/services/guardian/guardian_util_test.go @@ -0,0 +1,256 @@ +package guardian + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +type scenarioContext struct { + t *testing.T + orgRoleScenario string + permissionScenario string + g DashboardGuardian + givenUser *m.SignedInUser + givenDashboardID int64 + givenPermissions []*m.DashboardAclInfoDTO + givenTeams []*m.Team + updatePermissions []*m.DashboardAcl + expectedFlags permissionFlags + callerFile string + callerLine int +} + +type scenarioFunc func(c *scenarioContext) + +func orgRoleScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc) { + user := &m.SignedInUser{ + UserId: userID, + OrgId: orgID, + OrgRole: role, + } + guard := New(dashboardID, orgID, user) + sc := &scenarioContext{ + t: t, + orgRoleScenario: desc, + givenUser: user, + givenDashboardID: dashboardID, + g: guard, + } + + Convey(desc, func() { + fn(sc) + }) +} + +func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) { + bus.ClearBusHandlers() + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + if query.OrgId != sc.givenUser.OrgId { + sc.reportFailure("Invalid organization id for GetDashboardAclInfoListQuery", sc.givenUser.OrgId, query.OrgId) + } + if query.DashboardId != sc.givenDashboardID { + sc.reportFailure("Invalid dashboard id for GetDashboardAclInfoListQuery", sc.givenDashboardID, query.DashboardId) + } + + 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 { + if query.OrgId != sc.givenUser.OrgId { + sc.reportFailure("Invalid organization id for GetTeamsByUserQuery", sc.givenUser.OrgId, query.OrgId) + } + if query.UserId != sc.givenUser.UserId { + sc.reportFailure("Invalid user id for GetTeamsByUserQuery", sc.givenUser.UserId, query.UserId) + } + + query.Result = teams + return nil + }) + + sc.permissionScenario = desc + sc.g = New(dashboardID, sc.givenUser.OrgId, sc.givenUser) + sc.givenDashboardID = dashboardID + sc.givenPermissions = permissions + sc.givenTeams = teams + + Convey(desc, func() { + fn(sc) + }) +} + +type permissionType uint8 + +const ( + USER permissionType = 1 << iota + TEAM + EDITOR + VIEWER +) + +func (p permissionType) String() string { + names := map[uint8]string{ + uint8(USER): "user", + uint8(TEAM): "team", + uint8(EDITOR): "editor role", + uint8(VIEWER): "viewer role", + } + return names[uint8(p)] +} + +type permissionFlags uint8 + +const ( + NO_ACCESS permissionFlags = 1 << iota + CAN_ADMIN + CAN_EDIT + CAN_SAVE + CAN_VIEW + FULL_ACCESS = CAN_ADMIN | CAN_EDIT | CAN_SAVE | CAN_VIEW + EDITOR_ACCESS = CAN_EDIT | CAN_SAVE | CAN_VIEW + VIEWER_ACCESS = CAN_VIEW +) + +func (flag permissionFlags) canAdmin() bool { + return flag&CAN_ADMIN != 0 +} + +func (flag permissionFlags) canEdit() bool { + return flag&CAN_EDIT != 0 +} + +func (flag permissionFlags) canSave() bool { + return flag&CAN_SAVE != 0 +} + +func (flag permissionFlags) canView() bool { + return flag&CAN_VIEW != 0 +} + +func (flag permissionFlags) noAccess() bool { + return flag&(CAN_ADMIN|CAN_EDIT|CAN_SAVE|CAN_VIEW) == 0 +} + +func (f permissionFlags) String() string { + r := []string{} + + if f.canAdmin() { + r = append(r, "admin") + } + + if f.canEdit() { + r = append(r, "edit") + } + + if f.canSave() { + r = append(r, "save") + } + + if f.canView() { + r = append(r, "view") + } + + if f.noAccess() { + r = append(r, "") + } + + return strings.Join(r[:], ", ") +} + +func (sc *scenarioContext) reportSuccess() { + So(true, ShouldBeTrue) +} + +func (sc *scenarioContext) reportFailure(desc string, expected interface{}, actual interface{}) { + var buf bytes.Buffer + buf.WriteString("\n") + buf.WriteString(sc.orgRoleScenario) + buf.WriteString(" ") + buf.WriteString(sc.permissionScenario) + buf.WriteString("\n ") + buf.WriteString(desc) + buf.WriteString("\n") + buf.WriteString(fmt.Sprintf("Source test: %s:%d\n", sc.callerFile, sc.callerLine)) + buf.WriteString(fmt.Sprintf("Expected: %v\n", expected)) + buf.WriteString(fmt.Sprintf("Actual: %v\n", actual)) + buf.WriteString("Context:") + buf.WriteString(fmt.Sprintf("\n Given user: orgRole=%s, id=%d, orgId=%d", sc.givenUser.OrgRole, sc.givenUser.UserId, sc.givenUser.OrgId)) + buf.WriteString(fmt.Sprintf("\n Given dashboard id: %d", sc.givenDashboardID)) + + for i, p := range sc.givenPermissions { + r := "" + if p.Role != nil { + r = string(*p.Role) + } + buf.WriteString(fmt.Sprintf("\n Given permission (%d): dashboardId=%d, userId=%d, teamId=%d, role=%v, permission=%s", i, p.DashboardId, p.UserId, p.TeamId, r, p.Permission.String())) + } + + for i, t := range sc.givenTeams { + buf.WriteString(fmt.Sprintf("\n Given team (%d): id=%d", i, t.Id)) + } + + for i, p := range sc.updatePermissions { + r := "" + if p.Role != nil { + r = string(*p.Role) + } + buf.WriteString(fmt.Sprintf("\n Update permission (%d): dashboardId=%d, userId=%d, teamId=%d, role=%v, permission=%s", i, p.DashboardId, p.UserId, p.TeamId, r, p.Permission.String())) + } + + sc.t.Fatalf(buf.String()) +} + +func newCustomUserPermission(dashboardID int64, userID int64, permission m.PermissionType) *m.DashboardAcl { + return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, UserId: userID, Permission: permission} +} + +func newDefaultUserPermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl { + return newCustomUserPermission(dashboardID, userID, permission) +} + +func newCustomTeamPermission(dashboardID int64, teamID int64, permission m.PermissionType) *m.DashboardAcl { + return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, TeamId: teamID, Permission: permission} +} + +func newDefaultTeamPermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl { + return newCustomTeamPermission(dashboardID, teamID, permission) +} + +func newAdminRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl { + return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &adminRole, Permission: permission} +} + +func newEditorRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl { + return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &editorRole, Permission: permission} +} + +func newViewerRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl { + return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &viewerRole, Permission: permission} +} + +func toDto(acl *m.DashboardAcl) *m.DashboardAclInfoDTO { + return &m.DashboardAclInfoDTO{ + OrgId: acl.OrgId, + DashboardId: acl.DashboardId, + UserId: acl.UserId, + TeamId: acl.TeamId, + Role: acl.Role, + Permission: acl.Permission, + PermissionName: acl.Permission.String(), + } +} diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 05c2e53c748..1bac5025244 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -7,7 +7,6 @@ package notifications import ( "bytes" "crypto/tls" - "errors" "fmt" "html/template" "net" @@ -135,7 +134,7 @@ func buildEmailMessage(cmd *m.SendEmailCommand) (*Message, error) { subjectText, hasSubject := subjectData["value"] if !hasSubject { - return nil, errors.New(fmt.Sprintf("Missing subject in Template %s", cmd.Template)) + return nil, fmt.Errorf("Missing subject in Template %s", cmd.Template) } subjectTmpl, err := template.New("subject").Parse(subjectText.(string)) diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index dff5aa4924a..0636a6adadc 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -101,7 +101,3 @@ func sendWebRequestSync(ctx context.Context, webhook *Webhook) error { webhookLog.Debug("Webhook failed", "statuscode", resp.Status, "body", string(body)) return fmt.Errorf("Webhook response status %v", resp.Status) } - -var addToWebhookQueue = func(msg *Webhook) { - webhookQueue <- msg -} diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 9030ba609b9..8ac79df0fac 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -58,7 +58,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { files, err := ioutil.ReadDir(cr.path) if err != nil { - cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path) + cr.log.Error("can't read dashboard provisioning files from directory", "path", cr.path) return dashboards, nil } diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index f742b321552..4a55351d3e4 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -55,9 +55,6 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das dash.OrgId = cfg.OrgId dash.Dashboard.OrgId = cfg.OrgId dash.Dashboard.FolderId = folderId - if !cfg.Editable { - dash.Dashboard.Data.Set("editable", cfg.Editable) - } if dash.Dashboard.Title == "" { return nil, models.ErrDashboardTitleEmpty diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 58ed5472a6b..4b8931f0ed3 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -19,7 +19,7 @@ func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) files, err := ioutil.ReadDir(path) if err != nil { - cr.log.Error("cant read datasource provisioning files from directory", "path", path) + cr.log.Error("can't read datasource provisioning files from directory", "path", path) return datasources, nil } diff --git a/pkg/services/provisioning/datasources/config_reader_test.go b/pkg/services/provisioning/datasources/config_reader_test.go index 3198329e0ae..7d621ffe70f 100644 --- a/pkg/services/provisioning/datasources/config_reader_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -12,7 +12,6 @@ import ( var ( logger log.Logger = log.New("fake.log") - oneDatasourcesConfig string = "" twoDatasourcesConfig string = "./test-configs/two-datasources" twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two" doubleDatasourcesConfig string = "./test-configs/double-default" diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index b41ec37b797..71385994bb5 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -20,11 +20,7 @@ func Init(ctx context.Context, homePath string, cfg *ini.File) error { dashboardPath := path.Join(provisioningPath, "dashboards") _, err := dashboards.Provision(ctx, dashboardPath) - if err != nil { - return err - } - - return nil + return err } func makeAbsolute(path string, root string) string { diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index ae691c7166c..651241f7714 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -23,12 +23,7 @@ func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error { return inTransaction(func(sess *DBSession) error { sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?" _, err := sess.Exec(sql, cmd.OrgId, cmd.Id) - - if err != nil { - return err - } - - return nil + return err }) } diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index d37062fb58f..761114978a8 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -21,7 +21,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { } err := GetAlertNotifications(cmd) - fmt.Printf("errror %v", err) + fmt.Printf("error %v", err) So(err, ShouldBeNil) So(cmd.Result, ShouldBeNil) }) diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index 76f1819a18c..1066be0ef74 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" @@ -17,6 +18,12 @@ func (r *SqlAnnotationRepo) Save(item *annotations.Item) error { return inTransaction(func(sess *DBSession) error { tags := models.ParseTagPairs(item.Tags) item.Tags = models.JoinTagPairs(tags) + item.Created = time.Now().UnixNano() / int64(time.Millisecond) + item.Updated = item.Created + if item.Epoch == 0 { + item.Epoch = item.Created + } + if _, err := sess.Table("annotation").Insert(item); err != nil { return err } @@ -79,6 +86,7 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { return errors.New("Annotation not found") } + existing.Updated = time.Now().UnixNano() / int64(time.Millisecond) existing.Epoch = item.Epoch existing.Text = item.Text if item.RegionId != 0 { @@ -102,11 +110,8 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { existing.Tags = item.Tags - if _, err := sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "tags").Update(existing); err != nil { - return err - } - - return nil + _, err = sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "updated", "tags").Update(existing) + return err }) } @@ -127,6 +132,8 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I annotation.text, annotation.tags, annotation.data, + annotation.created, + annotation.updated, usr.email, usr.login, alert.name as alert_name @@ -164,6 +171,11 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I params = append(params, query.PanelId) } + if query.UserId != 0 { + sql.WriteString(` AND annotation.user_id = ?`) + params = append(params, query.UserId) + } + if query.From > 0 && query.To > 0 { sql.WriteString(` AND annotation.epoch BETWEEN ? AND ?`) params = append(params, query.From, query.To) @@ -171,6 +183,8 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I if query.Type == "alert" { sql.WriteString(` AND annotation.alert_id > 0`) + } else if query.Type == "annotation" { + sql.WriteString(` AND annotation.alert_id = 0`) } if len(query.Tags) > 0 { @@ -202,7 +216,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I } if query.Limit == 0 { - query.Limit = 10 + query.Limit = 100 } sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit)) diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index d5cee110b9a..5af5f271993 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -79,6 +79,12 @@ func TestAnnotations(t *testing.T) { Convey("Can read tags", func() { So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"}) }) + + Convey("Has created and updated values", func() { + So(items[0].Created, ShouldBeGreaterThan, 0) + So(items[0].Updated, ShouldBeGreaterThan, 0) + So(items[0].Updated, ShouldEqual, items[0].Created) + }) }) Convey("Can query for annotation by id", func() { @@ -231,6 +237,10 @@ func TestAnnotations(t *testing.T) { So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"}) So(items[0].Text, ShouldEqual, "something new") }) + + Convey("Updated time has increased", func() { + So(items[0].Updated, ShouldBeGreaterThan, items[0].Created) + }) }) Convey("Can delete annotation", func() { diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index 0532f636625..9d41b5c809e 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -55,7 +55,7 @@ func GetApiKeyById(query *m.GetApiKeyByIdQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrInvalidApiKey } @@ -69,7 +69,7 @@ func GetApiKeyByName(query *m.GetApiKeyByNameQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrInvalidApiKey } diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 8a89c3d942c..c0848f08863 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -63,7 +63,7 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { } // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && cmd.Overwrite == false { + if existing.PluginId != "" && !cmd.Overwrite { return m.UpdatePluginDashboardError{PluginId: existing.PluginId} } } @@ -172,7 +172,7 @@ func GetDashboard(query *m.GetDashboardQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrDashboardNotFound } @@ -308,7 +308,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { has, err := sess.Get(&dashboard) if err != nil { return err - } else if has == false { + } else if !has { return m.ErrDashboardNotFound } @@ -347,12 +347,7 @@ func GetDashboards(query *m.GetDashboardsQuery) error { err := x.In("id", query.DashboardIds).Find(&dashboards) query.Result = dashboards - - if err != nil { - return err - } - - return nil + return err } // GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s) @@ -431,12 +426,7 @@ func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error { err := x.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards) query.Result = dashboards - - if err != nil { - return err - } - - return nil + return err } type DashboardSlugDTO struct { @@ -451,7 +441,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error { if err != nil { return err - } else if exists == false { + } else if !exists { return m.ErrDashboardNotFound } @@ -479,7 +469,7 @@ func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error { if err != nil { return err - } else if exists == false { + } else if !exists { return m.ErrDashboardNotFound } @@ -544,6 +534,10 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash dash.SetId(existingByUid.Id) dash.SetUid(existingByUid.Uid) existing = existingByUid + + if !dash.IsFolder { + cmd.Result.IsParentFolderChanged = true + } } if (existing.IsFolder && !dash.IsFolder) || @@ -551,6 +545,10 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash return m.ErrDashboardTypeMismatch } + if !dash.IsFolder && dash.FolderId != existing.FolderId { + cmd.Result.IsParentFolderChanged = true + } + // check for is someone else has written in between if dash.Version != existing.Version { if cmd.Overwrite { @@ -561,7 +559,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash } // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && cmd.Overwrite == false { + if existing.PluginId != "" && !cmd.Overwrite { return m.UpdatePluginDashboardError{PluginId: existing.PluginId} } @@ -586,6 +584,10 @@ func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashbo return m.ErrDashboardFolderWithSameNameAsDashboard } + if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) { + cmd.Result.IsParentFolderChanged = true + } + if cmd.Overwrite { dash.SetId(existing.Id) dash.SetUid(existing.Uid) @@ -599,6 +601,7 @@ func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashbo } func ValidateDashboardBeforeSave(cmd *m.ValidateDashboardBeforeSaveCommand) (err error) { + cmd.Result = &m.ValidateDashboardBeforeSaveResult{} return inTransaction(func(sess *DBSession) error { if err = getExistingDashboardByIdOrUidForUpdate(sess, cmd); err != nil { return err diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index ae91d1d41f3..2034ccf0d30 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -35,10 +35,8 @@ func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error { // Update dashboard HasAcl flag dashboard := m.Dashboard{HasAcl: true} - if _, err := sess.Cols("has_acl").Where("id=?", cmd.DashboardId).Update(&dashboard); err != nil { - return err - } - return nil + _, err = sess.Cols("has_acl").Where("id=?", cmd.DashboardId).Update(&dashboard) + return err }) } @@ -92,6 +90,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { u.login AS user_login, u.email AS user_email, ug.name AS team, + ug.email AS team_email, d.title, d.slug, d.uid, diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index 69409c3b873..33fbb01c5b7 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -8,6 +8,7 @@ import ( func init() { bus.AddHandler("sql", GetProvisionedDashboardDataQuery) bus.AddHandler("sql", SaveProvisionedDashboard) + bus.AddHandler("sql", GetProvisionedDataByDashboardId) } type DashboardExtras struct { @@ -17,6 +18,19 @@ type DashboardExtras struct { Value string } +func GetProvisionedDataByDashboardId(cmd *models.IsDashboardProvisionedQuery) error { + result := &models.DashboardProvisioning{} + + exist, err := x.Where("dashboard_id = ?", cmd.DashboardId).Get(result) + if err != nil { + return err + } + + cmd.Result = exist + + return nil +} + func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error { return inTransaction(func(sess *DBSession) error { err := saveDashboard(sess, cmd.DashboardCmd) diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go index b752173b67d..7ef45df3152 100644 --- a/pkg/services/sqlstore/dashboard_provisioning_test.go +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -50,6 +50,23 @@ func TestDashboardProvisioningTest(t *testing.T) { So(query.Result[0].DashboardId, ShouldEqual, dashId) So(query.Result[0].Updated, ShouldEqual, now.Unix()) }) + + Convey("Can query for one provisioned dashboard", func() { + query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id} + + err := GetProvisionedDataByDashboardId(query) + So(err, ShouldBeNil) + + So(query.Result, ShouldBeTrue) + }) + + Convey("Can query for none provisioned dashboard", func() { + query := &models.IsDashboardProvisionedQuery{DashboardId: 3000} + + err := GetProvisionedDataByDashboardId(query) + So(err, ShouldBeNil) + So(query.Result, ShouldBeFalse) + }) }) }) } diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go index d005270c33c..a9658f7ab76 100644 --- a/pkg/services/sqlstore/dashboard_service_integration_test.go +++ b/pkg/services/sqlstore/dashboard_service_integration_test.go @@ -19,7 +19,6 @@ func TestIntegratedDashboardService(t *testing.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 }) @@ -28,6 +27,11 @@ func TestIntegratedDashboardService(t *testing.T) { return nil }) + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + cmd.Result = false + 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) @@ -74,7 +78,7 @@ func TestIntegratedDashboardService(t *testing.T) { Convey("Given organization B", func() { var otherOrgId int64 = 2 - Convey("When saving a dashboard with id that are saved in organization A", func() { + Convey("When creating a dashboard with same id as dashboard in organization A", func() { cmd := models.SaveDashboardCommand{ OrgId: otherOrgId, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -93,7 +97,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) 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() { + Convey("When creating a dashboard with same uid as dashboard in organization A", func() { var otherOrgId int64 = 2 cmd := models.SaveDashboardCommand{ OrgId: otherOrgId, @@ -106,7 +110,7 @@ func TestIntegratedDashboardService(t *testing.T) { res := callSaveWithResult(cmd) - Convey("It should create dashboard in other organization", func() { + Convey("It should create a new dashboard in organization B", func() { So(res, ShouldNotBeNil) query := models.GetDashboardQuery{OrgId: otherOrgId, Uid: savedDashInFolder.Uid} @@ -126,7 +130,7 @@ func TestIntegratedDashboardService(t *testing.T) { 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() { + Convey("When creating a new dashboard in the General folder", func() { cmd := models.SaveDashboardCommand{ OrgId: testOrgId, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -138,7 +142,7 @@ func TestIntegratedDashboardService(t *testing.T) { err := callSaveWithError(cmd) - Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + Convey("It should create dashboard guardian for General Folder with correct arguments and result in access denied error", func() { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) @@ -148,7 +152,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to create a new dashboard in other folder", func() { + Convey("When creating a new dashboard in other folder", func() { cmd := models.SaveDashboardCommand{ OrgId: testOrgId, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -161,7 +165,7 @@ func TestIntegratedDashboardService(t *testing.T) { err := callSaveWithError(cmd) - Convey("It should call dashboard guardian with correct arguments and rsult in access denied error", func() { + Convey("It should create dashboard guardian for other folder with correct arguments and rsult in access denied error", func() { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) @@ -171,7 +175,54 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update a dashboard by existing id in the General folder", func() { + Convey("When creating a new dashboard by existing title in folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": savedDashInFolder.Title, + }), + FolderId: savedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for folder with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When creating a new dashboard by existing uid in folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "New dash", + }), + FolderId: savedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for folder with correct arguments and result in access denied error", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) + + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) + }) + }) + + Convey("When updating a dashboard by existing id in the General folder", func() { cmd := models.SaveDashboardCommand{ OrgId: testOrgId, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -185,7 +236,7 @@ func TestIntegratedDashboardService(t *testing.T) { err := callSaveWithError(cmd) - Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + Convey("It should create dashboard guardian for dashboard with correct arguments and result in access denied error", func() { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) @@ -195,7 +246,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update a dashboard by existing id in other folder", func() { + Convey("When updating a dashboard by existing id in other folder", func() { cmd := models.SaveDashboardCommand{ OrgId: testOrgId, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -209,7 +260,7 @@ func TestIntegratedDashboardService(t *testing.T) { err := callSaveWithError(cmd) - Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() { + Convey("It should create dashboard guardian for dashboard with correct arguments and result in access denied error", func() { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) @@ -218,6 +269,102 @@ func TestIntegratedDashboardService(t *testing.T) { So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) + + Convey("When moving a dashboard by existing id to other folder from General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInGeneralFolder.Id, + "title": "Dash", + }), + FolderId: otherSavedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for other folder with correct arguments and result 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 moving a dashboard by existing id to the General folder from other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": savedDashInFolder.Id, + "title": "Dash", + }), + FolderId: 0, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for General folder 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 moving a dashboard by existing uid to other folder from General folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInGeneralFolder.Uid, + "title": "Dash", + }), + FolderId: otherSavedFolder.Id, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for other folder with correct arguments and result 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 moving a dashboard by existing uid to the General folder from other folder", func() { + cmd := models.SaveDashboardCommand{ + OrgId: testOrgId, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDashInFolder.Uid, + "title": "Dash", + }), + FolderId: 0, + UserId: 10000, + Overwrite: true, + } + + err := callSaveWithError(cmd) + + Convey("It should create dashboard guardian for General folder 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) + }) + }) }) // Given user has permission to save @@ -668,7 +815,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing folder to a dashboard using id", func() { + Convey("When updating existing folder to a dashboard using id", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -687,7 +834,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing dashboard to a folder using id", func() { + Convey("When updating existing dashboard to a folder using id", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -706,7 +853,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing folder to a dashboard using uid", func() { + Convey("When updating existing folder to a dashboard using uid", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -725,7 +872,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing dashboard to a folder using uid", func() { + Convey("When updating existing dashboard to a folder using uid", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -744,7 +891,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing folder to a dashboard using title", func() { + Convey("When updating existing folder to a dashboard using title", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -762,7 +909,7 @@ func TestIntegratedDashboardService(t *testing.T) { }) }) - Convey("When trying to update existing dashboard to a folder using title", func() { + Convey("When updating existing dashboard to a folder using title", func() { cmd := models.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -850,23 +997,6 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error { 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, diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 9e82bbb2c83..2e2ea8a4783 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -80,7 +80,7 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrDashboardSnapshotNotFound } diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index 8d2bf94bc42..7fac0001e5b 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -90,4 +90,29 @@ func addAnnotationMig(mg *Migrator) { Sqlite(updateTextFieldSql). Postgres(updateTextFieldSql). Mysql(updateTextFieldSql)) + + // + // Add a 'created' & 'updated' column + // + mg.AddMigration("Add created time to annotation table", NewAddColumnMigration(table, &Column{ + Name: "created", Type: DB_BigInt, Nullable: true, Default: "0", + })) + mg.AddMigration("Add updated time to annotation table", NewAddColumnMigration(table, &Column{ + Name: "updated", Type: DB_BigInt, Nullable: true, Default: "0", + })) + mg.AddMigration("Add index for created in annotation table", NewAddIndexMigration(table, &Index{ + Cols: []string{"org_id", "created"}, Type: IndexType, + })) + mg.AddMigration("Add index for updated in annotation table", NewAddIndexMigration(table, &Index{ + Cols: []string{"org_id", "updated"}, Type: IndexType, + })) + + // + // Convert epoch saved as seconds to miliseconds + // + updateEpochSql := "UPDATE annotation SET epoch = (epoch*1000) where epoch < 9999999999" + mg.AddMigration("Convert existing annotations from seconds to milliseconds", new(RawSqlMigration). + Sqlite(updateEpochSql). + Postgres(updateEpochSql). + Mysql(updateEpochSql)) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 282f98e7318..58ac6256f41 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -30,6 +30,7 @@ func AddMigrations(mg *Migrator) { addDashboardAclMigrations(mg) addTagMigration(mg) addLoginAttemptMigrations(mg) + addUserAuthMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 51aea0bbdef..8e8e4af824f 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -8,11 +8,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" . "github.com/smartystreets/goconvey/convey" - //"github.com/grafana/grafana/pkg/log" ) -var indexTypes = []string{"Unknown", "INDEX", "UNIQUE INDEX"} - func TestMigrations(t *testing.T) { testDBs := []sqlutil.TestDB{ sqlutil.TestDB_Sqlite3, diff --git a/pkg/services/sqlstore/migrations/user_auth_mig.go b/pkg/services/sqlstore/migrations/user_auth_mig.go new file mode 100644 index 00000000000..4d8a18ce33e --- /dev/null +++ b/pkg/services/sqlstore/migrations/user_auth_mig.go @@ -0,0 +1,24 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addUserAuthMigrations(mg *Migrator) { + userAuthV1 := Table{ + Name: "user_auth", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "user_id", Type: DB_BigInt, Nullable: false}, + {Name: "auth_module", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "auth_id", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"auth_module", "auth_id"}}, + }, + } + + // create table + mg.AddMigration("create user auth table", NewAddTableMigration(userAuthV1)) + // add indices + addTableIndicesMigrations(mg, "v1", userAuthV1) +} diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 064b5981063..dadc7248844 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -84,8 +84,7 @@ func (db *BaseDialect) DateTimeFunc(value string) string { } func (b *BaseDialect) CreateTableSql(table *Table) string { - var sql string - sql = "CREATE TABLE IF NOT EXISTS " + sql := "CREATE TABLE IF NOT EXISTS " sql += b.dialect.Quote(table.Name) + " (\n" pkList := table.PrimaryKeys @@ -162,8 +161,7 @@ func (db *BaseDialect) RenameTable(oldName string, newName string) string { func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string { quote := db.dialect.Quote - var name string - name = index.XName(tableName) + name := index.XName(tableName) return fmt.Sprintf("DROP INDEX %v ON %s", quote(name), quote(tableName)) } diff --git a/pkg/services/sqlstore/migrator/migrations.go b/pkg/services/sqlstore/migrator/migrations.go index 2fec8825fa4..4e6402a14e5 100644 --- a/pkg/services/sqlstore/migrator/migrations.go +++ b/pkg/services/sqlstore/migrator/migrations.go @@ -1,7 +1,6 @@ package migrator import ( - "fmt" "strings" ) @@ -113,7 +112,7 @@ func NewDropIndexMigration(table Table, index *Index) *DropIndexMigration { func (m *DropIndexMigration) Sql(dialect Dialect) string { if m.index.Name == "" { - m.index.Name = fmt.Sprintf("%s", strings.Join(m.index.Cols, "_")) + m.index.Name = strings.Join(m.index.Cols, "_") } return dialect.DropIndexSql(m.tableName, m.index) } @@ -180,7 +179,7 @@ type CopyTableDataMigration struct { targetTable string sourceCols []string targetCols []string - colMap map[string]string + //colMap map[string]string } func NewCopyTableDataMigration(targetTable string, sourceTable string, colMap map[string]string) *CopyTableDataMigration { diff --git a/pkg/services/sqlstore/migrator/types.go b/pkg/services/sqlstore/migrator/types.go index d42eba0f58a..62ec74e7b9f 100644 --- a/pkg/services/sqlstore/migrator/types.go +++ b/pkg/services/sqlstore/migrator/types.go @@ -46,7 +46,7 @@ type Index struct { func (index *Index) XName(tableName string) string { if index.Name == "" { - index.Name = fmt.Sprintf("%s", strings.Join(index.Cols, "_")) + index.Name = strings.Join(index.Cols, "_") } if !strings.HasPrefix(index.Name, "UQE_") && diff --git a/pkg/services/sqlstore/plugin_setting.go b/pkg/services/sqlstore/plugin_setting.go index 172995872eb..312a3bda523 100644 --- a/pkg/services/sqlstore/plugin_setting.go +++ b/pkg/services/sqlstore/plugin_setting.go @@ -36,7 +36,7 @@ func GetPluginSettingById(query *m.GetPluginSettingByIdQuery) error { has, err := x.Get(&pluginSetting) if err != nil { return err - } else if has == false { + } else if !has { return m.ErrPluginSettingNotFound } query.Result = &pluginSetting diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 3db3fc2657e..539555ddc50 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -31,7 +31,7 @@ func GetOrgQuotaByTarget(query *m.GetOrgQuotaByTargetQuery) error { has, err := x.Get("a) if err != nil { return err - } else if has == false { + } else if !has { quota.Limit = query.Default } @@ -108,7 +108,7 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { return err } quota.Limit = cmd.Limit - if has == false { + if !has { quota.Created = time.Now() //No quota in the DB for this target, so create a new one. if _, err := sess.Insert("a); err != nil { @@ -133,7 +133,7 @@ func GetUserQuotaByTarget(query *m.GetUserQuotaByTargetQuery) error { has, err := x.Get("a) if err != nil { return err - } else if has == false { + } else if !has { quota.Limit = query.Default } @@ -210,7 +210,7 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { return err } quota.Limit = cmd.Limit - if has == false { + if !has { quota.Created = time.Now() //No quota in the DB for this target, so create a new one. if _, err := sess.Insert("a); err != nil { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 6aace350193..782318fa188 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -258,7 +258,7 @@ func InitTestDB(t *testing.T) *xorm.Engine { // x.ShowSQL() if err != nil { - t.Fatalf("Failed to init in memory sqllite3 db %v", err) + t.Fatalf("Failed to init test database: %v", err) } sqlutil.CleanDB(x) @@ -269,3 +269,19 @@ func InitTestDB(t *testing.T) *xorm.Engine { return x } + +func IsTestDbMySql() bool { + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + return db == dbMySql + } + + return false +} + +func IsTestDbPostgres() bool { + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + return db == dbPostgres + } + + return false +} diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index cfe2d88c82c..47020d1a6f7 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -19,10 +19,6 @@ func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error { var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type` query.Result = make([]*m.DataSourceStats, 0) err := x.SQL(rawSql).Find(&query.Result) - if err != nil { - return err - } - return err } @@ -68,6 +64,7 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error { } query.Result = &stats + return err } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index d238301c7ce..b3ff4c81e7c 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -210,11 +210,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId) err := sess.Find(&query.Result) - if err != nil { - return err - } - - return nil + return err } // AddTeamMember adds a user to a team diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go index 43e1f027057..e93ba2fd641 100644 --- a/pkg/services/sqlstore/temp_user.go +++ b/pkg/services/sqlstore/temp_user.go @@ -126,7 +126,7 @@ func GetTempUserByCode(query *m.GetTempUserByCodeQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrTempUserNotFound } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index f42ff5fb2ed..1546fb83b21 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -154,7 +154,7 @@ func GetUserById(query *m.GetUserByIdQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrUserNotFound } @@ -179,7 +179,7 @@ func GetUserByLogin(query *m.GetUserByLoginQuery) error { return err } - if has == false && strings.Contains(query.LoginOrEmail, "@") { + if !has && strings.Contains(query.LoginOrEmail, "@") { // If the user wasn't found, and it contains an "@" fallback to finding the // user by email. user = &m.User{Email: query.LoginOrEmail} @@ -188,7 +188,7 @@ func GetUserByLogin(query *m.GetUserByLoginQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrUserNotFound } @@ -209,7 +209,7 @@ func GetUserByEmail(query *m.GetUserByEmailQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrUserNotFound } @@ -253,11 +253,8 @@ func ChangeUserPassword(cmd *m.ChangeUserPasswordCommand) error { Updated: time.Now(), } - if _, err := sess.Id(cmd.UserId).Update(&user); err != nil { - return err - } - - return nil + _, err := sess.Id(cmd.UserId).Update(&user) + return err }) } @@ -271,11 +268,8 @@ func UpdateUserLastSeenAt(cmd *m.UpdateUserLastSeenAtCommand) error { LastSeenAt: time.Now(), } - if _, err := sess.Id(cmd.UserId).Update(&user); err != nil { - return err - } - - return nil + _, err := sess.Id(cmd.UserId).Update(&user) + return err }) } @@ -295,11 +289,12 @@ func SetUsingOrg(cmd *m.SetUsingOrgCommand) error { } return inTransaction(func(sess *DBSession) error { - user := m.User{} - sess.Id(cmd.UserId).Get(&user) + user := m.User{ + Id: cmd.UserId, + OrgId: cmd.OrgId, + } - user.OrgId = cmd.OrgId - _, err := sess.Id(user.Id).Update(&user) + _, err := sess.Id(cmd.UserId).Update(&user) return err }) } @@ -310,7 +305,7 @@ func GetUserProfile(query *m.GetUserProfileQuery) error { if err != nil { return err - } else if has == false { + } else if !has { return m.ErrUserNotFound } @@ -333,6 +328,7 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error { sess.Join("INNER", "org", "org_user.org_id=org.id") sess.Where("org_user.user_id=?", query.UserId) sess.Cols("org.name", "org_user.role", "org_user.org_id") + sess.OrderBy("org.name") err := sess.Find(&query.Result) return err } @@ -444,6 +440,7 @@ func DeleteUser(cmd *m.DeleteUserCommand) error { "DELETE FROM dashboard_acl WHERE user_id = ?", "DELETE FROM preferences WHERE user_id = ?", "DELETE FROM team_member WHERE user_id = ?", + "DELETE FROM user_auth WHERE user_id = ?", } for _, sql := range deletes { @@ -478,10 +475,7 @@ func SetUserHelpFlag(cmd *m.SetUserHelpFlagCommand) error { Updated: time.Now(), } - if _, err := sess.Id(cmd.UserId).Cols("help_flags1").Update(&user); err != nil { - return err - } - - return nil + _, err := sess.Id(cmd.UserId).Cols("help_flags1").Update(&user) + return err }) } diff --git a/pkg/services/sqlstore/user_auth.go b/pkg/services/sqlstore/user_auth.go new file mode 100644 index 00000000000..aec828451a4 --- /dev/null +++ b/pkg/services/sqlstore/user_auth.go @@ -0,0 +1,148 @@ +package sqlstore + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", GetUserByAuthInfo) + bus.AddHandler("sql", GetAuthInfo) + bus.AddHandler("sql", SetAuthInfo) + bus.AddHandler("sql", DeleteAuthInfo) +} + +func GetUserByAuthInfo(query *m.GetUserByAuthInfoQuery) error { + user := &m.User{} + has := false + var err error + authQuery := &m.GetAuthInfoQuery{} + + // Try to find the user by auth module and id first + if query.AuthModule != "" && query.AuthId != "" { + authQuery.AuthModule = query.AuthModule + authQuery.AuthId = query.AuthId + + err = GetAuthInfo(authQuery) + if err != m.ErrUserNotFound { + if err != nil { + return err + } + + // if user id was specified and doesn't match the user_auth entry, remove it + if query.UserId != 0 && query.UserId != authQuery.Result.UserId { + err = DeleteAuthInfo(&m.DeleteAuthInfoCommand{ + UserAuth: authQuery.Result, + }) + if err != nil { + sqlog.Error("Error removing user_auth entry", "error", err) + } + + authQuery.Result = nil + } else { + has, err = x.Id(authQuery.Result.UserId).Get(user) + if err != nil { + return err + } + + if !has { + // if the user has been deleted then remove the entry + err = DeleteAuthInfo(&m.DeleteAuthInfoCommand{ + UserAuth: authQuery.Result, + }) + if err != nil { + sqlog.Error("Error removing user_auth entry", "error", err) + } + + authQuery.Result = nil + } + } + } + } + + // If not found, try to find the user by id + if !has && query.UserId != 0 { + has, err = x.Id(query.UserId).Get(user) + if err != nil { + return err + } + } + + // If not found, try to find the user by email address + if !has && query.Email != "" { + user = &m.User{Email: query.Email} + has, err = x.Get(user) + if err != nil { + return err + } + } + + // If not found, try to find the user by login + if !has && query.Login != "" { + user = &m.User{Login: query.Login} + has, err = x.Get(user) + if err != nil { + return err + } + } + + // No user found + if !has { + return m.ErrUserNotFound + } + + // create authInfo record to link accounts + if authQuery.Result == nil && query.AuthModule != "" && query.AuthId != "" { + cmd2 := &m.SetAuthInfoCommand{ + UserId: user.Id, + AuthModule: query.AuthModule, + AuthId: query.AuthId, + } + if err := SetAuthInfo(cmd2); err != nil { + return err + } + } + + query.Result = user + return nil +} + +func GetAuthInfo(query *m.GetAuthInfoQuery) error { + userAuth := &m.UserAuth{ + AuthModule: query.AuthModule, + AuthId: query.AuthId, + } + has, err := x.Get(userAuth) + if err != nil { + return err + } + if !has { + return m.ErrUserNotFound + } + + query.Result = userAuth + return nil +} + +func SetAuthInfo(cmd *m.SetAuthInfoCommand) error { + return inTransaction(func(sess *DBSession) error { + authUser := &m.UserAuth{ + UserId: cmd.UserId, + AuthModule: cmd.AuthModule, + AuthId: cmd.AuthId, + Created: time.Now(), + } + + _, err := sess.Insert(authUser) + return err + }) +} + +func DeleteAuthInfo(cmd *m.DeleteAuthInfoCommand) error { + return inTransaction(func(sess *DBSession) error { + _, err := sess.Delete(cmd.UserAuth) + return err + }) +} diff --git a/pkg/services/sqlstore/user_auth_test.go b/pkg/services/sqlstore/user_auth_test.go new file mode 100644 index 00000000000..279fd7aa0f5 --- /dev/null +++ b/pkg/services/sqlstore/user_auth_test.go @@ -0,0 +1,131 @@ +package sqlstore + +import ( + "fmt" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" +) + +func TestUserAuth(t *testing.T) { + InitTestDB(t) + + Convey("Given 5 users", t, func() { + var err error + var cmd *m.CreateUserCommand + users := []m.User{} + for i := 0; i < 5; i++ { + cmd = &m.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + } + err = CreateUser(cmd) + So(err, ShouldBeNil) + users = append(users, cmd.Result) + } + + Reset(func() { + _, err := x.Exec("DELETE FROM org_user WHERE 1=1") + So(err, ShouldBeNil) + _, err = x.Exec("DELETE FROM org WHERE 1=1") + So(err, ShouldBeNil) + _, err = x.Exec("DELETE FROM user WHERE 1=1") + So(err, ShouldBeNil) + _, err = x.Exec("DELETE FROM user_auth WHERE 1=1") + So(err, ShouldBeNil) + }) + + Convey("Can find existing user", func() { + // By Login + login := "loginuser0" + + query := &m.GetUserByAuthInfoQuery{Login: login} + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Login, ShouldEqual, login) + + // By ID + id := query.Result.Id + + query = &m.GetUserByAuthInfoQuery{UserId: id} + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Id, ShouldEqual, id) + + // By Email + email := "user1@test.com" + + query = &m.GetUserByAuthInfoQuery{Email: email} + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Email, ShouldEqual, email) + + // Don't find nonexistent user + email = "nonexistent@test.com" + + query = &m.GetUserByAuthInfoQuery{Email: email} + err = GetUserByAuthInfo(query) + + So(err, ShouldEqual, m.ErrUserNotFound) + So(query.Result, ShouldBeNil) + }) + + Convey("Can set & locate by AuthModule and AuthId", func() { + // get nonexistent user_auth entry + query := &m.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + err = GetUserByAuthInfo(query) + + So(err, ShouldEqual, m.ErrUserNotFound) + So(query.Result, ShouldBeNil) + + // create user_auth entry + login := "loginuser0" + + query.Login = login + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Login, ShouldEqual, login) + + // get via user_auth + query = &m.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Login, ShouldEqual, login) + + // get with non-matching id + id := query.Result.Id + + query.UserId = id + 1 + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Login, ShouldEqual, "loginuser1") + + // get via user_auth + query = &m.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + err = GetUserByAuthInfo(query) + + So(err, ShouldBeNil) + So(query.Result.Login, ShouldEqual, "loginuser1") + + // remove user + _, err = x.Exec("DELETE FROM user WHERE id=?", query.Result.Id) + So(err, ShouldBeNil) + + // get via user_auth for deleted user + query = &m.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + err = GetUserByAuthInfo(query) + + So(err, ShouldEqual, m.ErrUserNotFound) + So(query.Result, ShouldBeNil) + }) + }) +} diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index b92d64ad9fc..8c02076096d 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -182,7 +182,7 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) var data UserInfoJson var err error - if s.extractToken(&data, token) != true { + if !s.extractToken(&data, token) { response, err := HttpGet(client, s.apiUrl) if err != nil { return nil, fmt.Errorf("Error getting user info: %s", err) diff --git a/pkg/social/grafana_com_oauth.go b/pkg/social/grafana_com_oauth.go index d3614520d61..87601788c3f 100644 --- a/pkg/social/grafana_com_oauth.go +++ b/pkg/social/grafana_com_oauth.go @@ -51,6 +51,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { + Id int `json:"id"` Name string `json:"name"` Login string `json:"username"` Email string `json:"email"` @@ -69,6 +70,7 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (* } userInfo := &BasicUserInfo{ + Id: fmt.Sprintf("%d", data.Id), Name: data.Name, Login: data.Login, Email: data.Email, diff --git a/pkg/social/social.go b/pkg/social/social.go index b763e2d71b2..8f0618b7f74 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -14,6 +14,7 @@ import ( ) type BasicUserInfo struct { + Id string Name string Email string Login string diff --git a/pkg/tsdb/cloudwatch/annotation_query.go b/pkg/tsdb/cloudwatch/annotation_query.go index 287f4e770ef..e0d9158435e 100644 --- a/pkg/tsdb/cloudwatch/annotation_query.go +++ b/pkg/tsdb/cloudwatch/annotation_query.go @@ -72,7 +72,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo MetricName: aws.String(metricName), Dimensions: qd, Statistic: aws.String(s), - Period: aws.Int64(int64(period)), + Period: aws.Int64(period), } resp, err := svc.DescribeAlarmsForMetric(params) if err != nil { @@ -88,7 +88,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo MetricName: aws.String(metricName), Dimensions: qd, ExtendedStatistic: aws.String(s), - Period: aws.Int64(int64(period)), + Period: aws.Int64(period), } resp, err := svc.DescribeAlarmsForMetric(params) if err != nil { diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 3879dce4ea6..d98805c661d 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -71,15 +71,12 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc switch queryType { case "metricFindQuery": result, err = e.executeMetricFindQuery(ctx, queryContext) - break case "annotationQuery": result, err = e.executeAnnotationQuery(ctx, queryContext) - break case "timeSeriesQuery": fallthrough default: result, err = e.executeTimeSeriesQuery(ctx, queryContext) - break } return result, err diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index c82cff390c3..d73516ca88f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -175,25 +175,18 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo switch subType { case "regions": data, err = e.handleGetRegions(ctx, parameters, queryContext) - break case "namespaces": data, err = e.handleGetNamespaces(ctx, parameters, queryContext) - break case "metrics": data, err = e.handleGetMetrics(ctx, parameters, queryContext) - break case "dimension_keys": data, err = e.handleGetDimensions(ctx, parameters, queryContext) - break case "dimension_values": data, err = e.handleGetDimensionValues(ctx, parameters, queryContext) - break case "ebs_volume_ids": data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext) - break case "ec2_instance_attribute": data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext) - break } transformToTable(data, queryResult) @@ -261,7 +254,7 @@ func (e *CloudWatchExecutor) handleGetNamespaces(ctx context.Context, parameters keys = append(keys, strings.Split(customNamespaces, ",")...) } - sort.Sort(sort.StringSlice(keys)) + sort.Strings(keys) result := make([]suggestData, 0) for _, key := range keys { @@ -290,7 +283,7 @@ func (e *CloudWatchExecutor) handleGetMetrics(ctx context.Context, parameters *s return nil, errors.New("Unable to call AWS API") } } - sort.Sort(sort.StringSlice(namespaceMetrics)) + sort.Strings(namespaceMetrics) result := make([]suggestData, 0) for _, name := range namespaceMetrics { @@ -319,7 +312,7 @@ func (e *CloudWatchExecutor) handleGetDimensions(ctx context.Context, parameters return nil, errors.New("Unable to call AWS API") } } - sort.Sort(sort.StringSlice(dimensionValues)) + sort.Strings(dimensionValues) result := make([]suggestData, 0) for _, name := range dimensionValues { @@ -573,11 +566,7 @@ func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) } return !lastPage }) - if err != nil { - return resp, err - } - - return resp, nil + return resp, err } var metricsCacheLock sync.Mutex diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index bf87e7b7d41..e3903e8027e 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -181,10 +181,7 @@ func TestCloudWatchMetrics(t *testing.T) { } func TestParseMultiSelectValue(t *testing.T) { - - var values []string - - values = parseMultiSelectValue(" i-someInstance ") + values := parseMultiSelectValue(" i-someInstance ") assert.Equal(t, []string{"i-someInstance"}, values) values = parseMultiSelectValue("{i-05}") diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index 9d41cd03255..bb9489cd654 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -82,11 +82,11 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= DATEADD(s, %d, '1970-01-01') AND %s <= DATEADD(s, %d, '1970-01-01')", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%s >= DATEADD(s, %d, '1970-01-01') AND %s <= DATEADD(s, %d, '1970-01-01')", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil case "__timeFrom": - return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetFromAsSecondsEpoch()), nil case "__timeTo": - return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) @@ -113,11 +113,11 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), 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 index 12a9b0d82be..ae0d4f67d2b 100644 --- a/pkg/tsdb/mssql/macros_test.go +++ b/pkg/tsdb/mssql/macros_test.go @@ -1,6 +1,8 @@ package mssql import ( + "fmt" + "strconv" "testing" "time" @@ -13,112 +15,213 @@ import ( 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) + Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := tsdb.NewFakeTimeRange("5m", "now", to) - So(sql, ShouldEqual, "select time_column AS time") + 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, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + }) + + 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, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __timeEpoch function", func() { - sql, err := engine.Interpolate(query, nil, "select $__timeEpoch(time_column)") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - So(sql, ShouldEqual, "select 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, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __timeEpoch function wrapped in aggregation", func() { - sql, err := engine.Interpolate(query, nil, "select min($__timeEpoch(time_column))") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - 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) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + }) - So(sql, ShouldEqual, "WHERE time_column >= DATEADD(s, 18446744066914186738, '1970-01-01') AND time_column <= DATEADD(s, 18446744066914187038, '1970-01-01')") - }) + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) - Convey("interpolate __timeGroup function", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") - So(err, ShouldBeNil) + So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + }) - So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300") - }) + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) - 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, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + }) - So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300") - }) + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + So(err, ShouldBeNil) - Convey("interpolate __timeGroup function with fill (value = NULL)", func() { - _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)") + So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) + }) - fill := query.Model.Get("fill").MustBool() - fillNull := query.Model.Get("fillNull").MustBool() - fillInterval := query.Model.Get("fillInterval").MustInt() + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) - So(err, ShouldBeNil) - So(fill, ShouldBeTrue) - So(fillNull, ShouldBeTrue) - So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) - }) + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) - Convey("interpolate __timeGroup function with fill (value = float)", func() { - _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)") + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) - 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") + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) }) } diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 2638fd8bb40..a598b7239ed 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -8,8 +8,6 @@ import ( "strconv" "strings" - "time" - "math" _ "github.com/denisenkom/go-mssqldb" @@ -147,7 +145,7 @@ func (e MssqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core. // convert types not handled by denisenkom/go-mssqldb // unhandled types are returned as []byte for i := 0; i < len(types); i++ { - if value, ok := values[i].([]byte); ok == true { + if value, ok := values[i].([]byte); ok { switch types[i].DatabaseTypeName() { case "MONEY", "SMALLMONEY", "DECIMAL": if v, err := strconv.ParseFloat(string(value), 64); err == nil { @@ -211,7 +209,7 @@ func (e MssqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. fillValue := null.Float{} if fillMissing { fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if query.Model.Get("fillNull").MustBool(false) == false { + if !query.Model.Get("fillNull").MustBool(false) { fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() fillValue.Valid = true } @@ -231,19 +229,22 @@ func (e MssqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. return err } + // converts column named time to unix timestamp in milliseconds to make + // native mysql datetime types and epoch dates work in + // annotation and table queries. + tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) + switch columnValue := values[timeIndex].(type) { case int64: - timestamp = float64(columnValue * 1000) + timestamp = float64(columnValue) 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 + timestamp = columnValue 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 { + if columnValue, ok := values[metricIndex].(string); ok { metric = columnValue } else { return fmt.Errorf("Column metric must be of type CHAR, VARCHAR, NCHAR or NVARCHAR. metric column name: %s type: %s but datatype is %T", columnNames[metricIndex], columnTypes[metricIndex].DatabaseTypeName(), values[metricIndex]) @@ -270,7 +271,7 @@ func (e MssqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. } series, exist := pointsBySeries[metric] - if exist == false { + if !exist { series = &tsdb.TimeSeries{Name: metric} pointsBySeries[metric] = series seriesByQueryOrder.PushBack(metric) @@ -278,7 +279,7 @@ func (e MssqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. if fillMissing { var intervalStart float64 - if exist == false { + if !exist { intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) } else { intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 4bd1e3a8ad7..599f4869f6a 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -16,10 +16,10 @@ import ( ) // To run this test, remove the Skip from SkipConvey -// and set up a MSSQL db named grafanatest and a user/password grafana/Password! +// The tests require a MSSQL db named grafanatest and a user/password grafana/Password! // Use the docker/blocks/mssql_tests/docker-compose.yaml to spin up a // preconfigured MSSQL server suitable for running these tests. -// Thers's also a dashboard.json in same directory that you can import to Grafana +// There is also a dashboard.json in same directory that you can import to Grafana // once you've created a datasource for the test server/database. // If needed, change the variable below to the IP address of the database. var serverIP string = "localhost" @@ -188,10 +188,8 @@ func TestMSSQL(t *testing.T) { }) } - for _, s := range series { - _, err = sess.Insert(s) - So(err, ShouldBeNil) - } + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) Convey("When doing a metric query using timeGroup", func() { query := &tsdb.TsdbQuery{ @@ -312,10 +310,18 @@ func TestMSSQL(t *testing.T) { 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'"` + Time time.Time + TimeInt64 int64 `xorm:"bigint 'timeInt64' not null"` + TimeInt64Nullable *int64 `xorm:"bigint 'timeInt64Nullable' null"` + TimeFloat64 float64 `xorm:"float 'timeFloat64' not null"` + TimeFloat64Nullable *float64 `xorm:"float 'timeFloat64Nullable' null"` + TimeInt32 int32 `xorm:"int(11) 'timeInt32' not null"` + TimeInt32Nullable *int32 `xorm:"int(11) 'timeInt32Nullable' null"` + TimeFloat32 float32 `xorm:"float(11) 'timeFloat32' not null"` + TimeFloat32Nullable *float32 `xorm:"float(11) 'timeFloat32Nullable' null"` + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` } if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { @@ -330,26 +336,219 @@ func TestMSSQL(t *testing.T) { return rand.Int63n(max-min) + min } + var tInitial time.Time + 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 i, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + if i == 0 { + tInitial = t + } + tSeconds := t.Unix() + tSecondsInt32 := int32(tSeconds) + tSecondsFloat32 := float32(tSeconds) + tMilliseconds := tSeconds * 1e3 + tMillisecondsFloat := float64(tMilliseconds) + first := metric_values{ + Time: t, + TimeInt64: tMilliseconds, + TimeInt64Nullable: &(tMilliseconds), + TimeFloat64: tMillisecondsFloat, + TimeFloat64Nullable: &tMillisecondsFloat, + TimeInt32: tSecondsInt32, + TimeInt32Nullable: &tSecondsInt32, + TimeFloat32: tSecondsFloat32, + TimeFloat32Nullable: &tSecondsFloat32, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + } + second := first + second.Measurement = "Metric B" + second.ValueOne = rnd(0, 100) + second.ValueTwo = rnd(0, 100) + + series = append(series, &first) + series = append(series, &second) } - for _, s := range series { - _, err = sess.Insert(s) + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) + + Convey("When doing a metric query using epoch (int64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeInt64 as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeInt64Nullable as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeFloat64 as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeFloat64Nullable as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeInt32 as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeInt32Nullable as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeFloat32 as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) + + Convey("When doing a metric query using epoch (float32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT TOP 1 timeFloat32Nullable as time, valueOne FROM metric_values ORDER BY time`, + "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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { query := &tsdb.TsdbQuery{ @@ -476,7 +675,6 @@ func TestMSSQL(t *testing.T) { 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) @@ -696,7 +894,7 @@ func TestMSSQL(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + So(columns[0].(float64), ShouldEqual, float64(dt.UnixNano()/1e6)) }) Convey("When doing an annotation query with a time column in epoch second format should return ms", func() { @@ -850,15 +1048,15 @@ func TestMSSQL(t *testing.T) { func InitMSSQLTestDB(t *testing.T) *xorm.Engine { x, err := xorm.NewEngine(sqlutil.TestDB_Mssql.DriverName, strings.Replace(sqlutil.TestDB_Mssql.ConnStr, "localhost", serverIP, 1)) + if err != nil { + t.Fatalf("Failed to init mssql db %v", err) + } + x.DatabaseTZ = time.UTC x.TZLocation = time.UTC // x.ShowSQL() - if err != nil { - t.Fatalf("Failed to init mssql db %v", err) - } - return x } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index a292f209429..fadcbe4edbc 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -77,11 +77,11 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil case "__timeFrom": - return fmt.Sprintf("FROM_UNIXTIME(%d)", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetFromAsSecondsEpoch()), nil case "__timeTo": - return fmt.Sprintf("FROM_UNIXTIME(%d)", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) @@ -108,11 +108,11 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), nil default: return "", fmt.Errorf("Unknown macro %v", name) } diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index a89ba16ab78..66ec143eac8 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -1,7 +1,10 @@ package mysql import ( + "fmt" + "strconv" "testing" + "time" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" @@ -11,79 +14,179 @@ 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(query, timeRange, "select $__time(time_column)") - So(err, ShouldBeNil) + Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := tsdb.NewFakeTimeRange("5m", "now", to) - So(sql, ShouldEqual, "select UNIX_TIMESTAMP(time_column) as time_sec") + Convey("interpolate __time function", func() { + 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(query, timeRange, "select min($__time(time_column))") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select min(UNIX_TIMESTAMP(time_column) as time_sec)") + }) + + 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(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 __timeFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __time function wrapped in aggregation", func() { - sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - So(sql, ShouldEqual, "select min(UNIX_TIMESTAMP(time_column) as time_sec)") + Convey("interpolate __timeFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - So(sql, ShouldEqual, "WHERE time_column >= FROM_UNIXTIME(18446744066914186738) AND time_column <= FROM_UNIXTIME(18446744066914187038)") + Convey("interpolate __timeFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - - Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select FROM_UNIXTIME(18446744066914186738)") - }) - - 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(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(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(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(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/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 483974c55a4..4f5cd1b0784 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -8,7 +8,6 @@ import ( "math" "reflect" "strconv" - "time" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" @@ -219,7 +218,7 @@ func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. fillValue := null.Float{} if fillMissing { fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if query.Model.Get("fillNull").MustBool(false) == false { + if !query.Model.Get("fillNull").MustBool(false) { fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() fillValue.Valid = true } @@ -239,19 +238,22 @@ func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. return err } + // converts column named time to unix timestamp in milliseconds to make + // native mysql datetime types and epoch dates work in + // annotation and table queries. + tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) + switch columnValue := values[timeIndex].(type) { case int64: - timestamp = float64(columnValue * 1000) + timestamp = float64(columnValue) case float64: - timestamp = columnValue * 1000 - case time.Time: - timestamp = float64(columnValue.UnixNano() / 1e6) + timestamp = columnValue default: - return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) + return fmt.Errorf("Invalid type for column time/time_sec, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) } if metricIndex >= 0 { - if columnValue, ok := values[metricIndex].(string); ok == true { + if columnValue, ok := values[metricIndex].(string); ok { metric = columnValue } else { return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) @@ -278,7 +280,7 @@ func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. } series, exist := pointsBySeries[metric] - if exist == false { + if !exist { series = &tsdb.TimeSeries{Name: metric} pointsBySeries[metric] = series seriesByQueryOrder.PushBack(metric) @@ -286,7 +288,7 @@ func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core. if fillMissing { var intervalStart float64 - if exist == false { + if !exist { intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) } else { intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 750704c9965..74cedea803a 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -3,25 +3,36 @@ package mysql 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" "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 MySQL db named grafana_tests and a user/password grafana/password +// To run this test, set runMySqlTests=true +// Or from the commandline: GRAFANA_TEST_DB=mysql go test -v ./pkg/tsdb/mysql +// The tests require a MySQL db named grafana_ds_tests and a user/password grafana/password // Use the docker/blocks/mysql_tests/docker-compose.yaml to spin up a // preconfigured MySQL server suitable for running these tests. -// Thers's also a dashboard.json in same directory that you can import to Grafana +// There is also a dashboard.json in same directory that you can import to Grafana // once you've created a datasource for the test server/database. func TestMySQL(t *testing.T) { - SkipConvey("MySQL", t, func() { + // change to true to run the MySQL tests + runMySqlTests := false + // runMySqlTests := true + + if !(sqlstore.IsTestDbMySql() || runMySqlTests) { + t.Skip() + } + + Convey("MySQL", t, func() { x := InitMySQLTestDB(t) endpoint := &MysqlQueryEndpoint{ @@ -35,7 +46,7 @@ func TestMySQL(t *testing.T) { sess := x.NewSession() defer sess.Close() - fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.Local) + fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC) Convey("Given a table with different native data types", func() { if exists, err := sess.IsTableExist("mysql_types"); err != nil || exists { @@ -121,9 +132,8 @@ func TestMySQL(t *testing.T) { So(column[7].(float64), ShouldEqual, 1.11) So(column[8].(float64), ShouldEqual, 2.22) So(*column[9].(*float32), ShouldEqual, 3.33) - _, 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[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now()) + So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now()) So(column[12].(string), ShouldEqual, "11:11:11") So(column[13].(int64), ShouldEqual, 2018) So(*column[14].(*[]byte), ShouldHaveSameTypeAs, []byte{1}) @@ -137,8 +147,8 @@ func TestMySQL(t *testing.T) { 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[25].(time.Time).Format("2006-01-02T00:00:00Z"), ShouldEqual, time.Now().UTC().Format("2006-01-02T00:00:00Z")) + So(column[26].(float64), ShouldEqual, float64(1.514764861123456*1e12)) So(column[27], ShouldEqual, nil) So(column[28], ShouldEqual, nil) So(column[29], ShouldEqual, "") @@ -177,10 +187,8 @@ func TestMySQL(t *testing.T) { }) } - for _, s := range series { - _, err = sess.Insert(s) - So(err, ShouldBeNil) - } + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) Convey("When doing a metric query using timeGroup", func() { query := &tsdb.TsdbQuery{ @@ -301,10 +309,19 @@ func TestMySQL(t *testing.T) { 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'"` + Time time.Time `xorm:"datetime 'time' not null"` + TimeNullable *time.Time `xorm:"datetime(6) 'timeNullable' null"` + TimeInt64 int64 `xorm:"bigint(20) 'timeInt64' not null"` + TimeInt64Nullable *int64 `xorm:"bigint(20) 'timeInt64Nullable' null"` + TimeFloat64 float64 `xorm:"double 'timeFloat64' not null"` + TimeFloat64Nullable *float64 `xorm:"double 'timeFloat64Nullable' null"` + TimeInt32 int32 `xorm:"int(11) 'timeInt32' not null"` + TimeInt32Nullable *int32 `xorm:"int(11) 'timeInt32Nullable' null"` + TimeFloat32 float32 `xorm:"double 'timeFloat32' not null"` + TimeFloat32Nullable *float32 `xorm:"double 'timeFloat32Nullable' null"` + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` } if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { @@ -319,26 +336,265 @@ func TestMySQL(t *testing.T) { return rand.Int63n(max-min) + min } + var tInitial time.Time + 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 i, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + if i == 0 { + tInitial = t + } + tSeconds := t.Unix() + tSecondsInt32 := int32(tSeconds) + tSecondsFloat32 := float32(tSeconds) + tMilliseconds := tSeconds * 1e3 + tMillisecondsFloat := float64(tMilliseconds) + t2 := t + first := metric_values{ + Time: t, + TimeNullable: &t2, + TimeInt64: tMilliseconds, + TimeInt64Nullable: &(tMilliseconds), + TimeFloat64: tMillisecondsFloat, + TimeFloat64Nullable: &tMillisecondsFloat, + TimeInt32: tSecondsInt32, + TimeInt32Nullable: &tSecondsInt32, + TimeFloat32: tSecondsFloat32, + TimeFloat32Nullable: &tSecondsFloat32, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + } + second := first + second.Measurement = "Metric B" + second.ValueOne = rnd(0, 100) + second.ValueTwo = rnd(0, 100) + + series = append(series, &first) + series = append(series, &second) } - for _, s := range series { - _, err := sess.Insert(s) + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) + + Convey("When doing a metric query using time as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using time (nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeNullable as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeInt64 as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeInt64Nullable as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeFloat64 as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeFloat64Nullable as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeInt32 as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeInt32Nullable as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeFloat32 as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) + + Convey("When doing a metric query using epoch (float32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT timeFloat32Nullable as time, valueOne FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { query := &tsdb.TsdbQuery{ @@ -647,16 +903,16 @@ func TestMySQL(t *testing.T) { } 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() - + x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, strings.Replace(sqlutil.TestDB_Mysql.ConnStr, "/grafana_tests", "/grafana_ds_tests", 1)) if err != nil { t.Fatalf("Failed to init mysql db %v", err) } + x.DatabaseTZ = time.UTC + x.TZLocation = time.UTC + + // x.ShowSQL() + return x } diff --git a/pkg/tsdb/opentsdb/opentsdb_test.go b/pkg/tsdb/opentsdb/opentsdb_test.go index 094deb9e8ec..fe03599f54d 100644 --- a/pkg/tsdb/opentsdb/opentsdb_test.go +++ b/pkg/tsdb/opentsdb/opentsdb_test.go @@ -35,7 +35,7 @@ func TestOpenTsdbExecutor(t *testing.T) { }) - Convey("Build metric with downsampling diabled", func() { + Convey("Build metric with downsampling disabled", func() { query := &tsdb.Query{ Model: simplejson.New(), diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 23daeebec5a..05e39f2c762 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -79,15 +79,15 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, } return fmt.Sprintf("extract(epoch from %s) as \"time\"", args[0]), nil case "__timeFilter": - // dont use to_timestamp in this macro for redshift compatibility #9566 if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("extract(epoch from %s) BETWEEN %d AND %d", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + + return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeFrom": - return fmt.Sprintf("to_timestamp(%d)", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("'%s'", m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil case "__timeTo": - return fmt.Sprintf("to_timestamp(%d)", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("'%s'", m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) @@ -114,11 +114,11 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, 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 + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), nil default: return "", fmt.Errorf("Unknown macro %v", name) } diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index b18acced963..c3c15691e42 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/pkg/tsdb/postgres/macros_test.go @@ -1,7 +1,10 @@ package postgres import ( + "fmt" + "strconv" "testing" + "time" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" @@ -9,81 +12,181 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { - engine := &PostgresMacroEngine{} + engine := NewPostgresMacroEngine() query := &tsdb.Query{} - timeRange := &tsdb.TimeRange{From: "5m", To: "now"} - Convey("interpolate __time function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__time(time_column)") - So(err, ShouldBeNil) + Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := tsdb.NewFakeTimeRange("5m", "now", to) - So(sql, ShouldEqual, "select time_column AS \"time\"") + Convey("interpolate __time function", func() { + 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(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(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) + }) + + 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 (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(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __time function wrapped in aggregation", func() { - sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - So(sql, ShouldEqual, "select min(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, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) + Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10)) - So(sql, ShouldEqual, "WHERE extract(epoch from time_column) BETWEEN 18446744066914186738 AND 18446744066914187038") + Convey("interpolate __timeFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix())) + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) + }) }) - - Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select to_timestamp(18446744066914186738)") - }) - - 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 (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(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(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(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/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index 5f6b56ebcf1..72d50b32d04 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -7,7 +7,6 @@ import ( "math" "net/url" "strconv" - "time" "github.com/go-xorm/core" "github.com/grafana/grafana/pkg/components/null" @@ -132,7 +131,7 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, // convert types not handled by lib/pq // unhandled types are returned as []byte for i := 0; i < len(types); i++ { - if value, ok := values[i].([]byte); ok == true { + if value, ok := values[i].([]byte); ok { switch types[i].DatabaseTypeName() { case "NUMERIC": if v, err := strconv.ParseFloat(string(value), 64); err == nil { @@ -199,7 +198,7 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co fillValue := null.Float{} if fillMissing { fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if query.Model.Get("fillNull").MustBool(false) == false { + if !query.Model.Get("fillNull").MustBool(false) { fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() fillValue.Valid = true } @@ -219,19 +218,22 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co return err } + // converts column named time to unix timestamp in milliseconds to make + // native mysql datetime types and epoch dates work in + // annotation and table queries. + tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) + switch columnValue := values[timeIndex].(type) { case int64: - timestamp = float64(columnValue * 1000) + timestamp = float64(columnValue) case float64: - timestamp = columnValue * 1000 - case time.Time: - timestamp = float64(columnValue.UnixNano() / 1e6) + timestamp = columnValue default: return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) } if metricIndex >= 0 { - if columnValue, ok := values[metricIndex].(string); ok == true { + if columnValue, ok := values[metricIndex].(string); ok { metric = columnValue } else { return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) @@ -258,7 +260,7 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co } series, exist := pointsBySeries[metric] - if exist == false { + if !exist { series = &tsdb.TimeSeries{Name: metric} pointsBySeries[metric] = series seriesByQueryOrder.PushBack(metric) @@ -266,7 +268,7 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co if fillMissing { var intervalStart float64 - if exist == false { + if !exist { intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) } else { intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index 3f2203ac7a4..d18251bac7d 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -3,26 +3,37 @@ package postgres 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" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" _ "github.com/lib/pq" . "github.com/smartystreets/goconvey/convey" ) -// To run this test, remove the Skip from SkipConvey -// and set up a PostgreSQL db named grafanatest and a user/password grafanatest/grafanatest! +// To run this test, set runPostgresTests=true +// Or from the commandline: GRAFANA_TEST_DB=postgres go test -v ./pkg/tsdb/postgres +// The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! // Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a // preconfigured Postgres server suitable for running these tests. -// Thers's also a dashboard.json in same directory that you can import to Grafana +// There is also a dashboard.json in same directory that you can import to Grafana // once you've created a datasource for the test server/database. func TestPostgres(t *testing.T) { - SkipConvey("PostgreSQL", t, func() { + // change to true to run the MySQL tests + runPostgresTests := false + // runPostgresTests := true + + if !(sqlstore.IsTestDbPostgres() || runPostgresTests) { + t.Skip() + } + + Convey("PostgreSQL", t, func() { x := InitPostgresTestDB(t) endpoint := &PostgresQueryEndpoint{ @@ -156,10 +167,8 @@ func TestPostgres(t *testing.T) { }) } - for _, s := range series { - _, err = sess.Insert(s) - So(err, ShouldBeNil) - } + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) Convey("When doing a metric query using timeGroup", func() { query := &tsdb.TsdbQuery{ @@ -280,10 +289,18 @@ func TestPostgres(t *testing.T) { 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'"` + Time time.Time + TimeInt64 int64 `xorm:"bigint 'timeInt64' not null"` + TimeInt64Nullable *int64 `xorm:"bigint 'timeInt64Nullable' null"` + TimeFloat64 float64 `xorm:"double 'timeFloat64' not null"` + TimeFloat64Nullable *float64 `xorm:"double 'timeFloat64Nullable' null"` + TimeInt32 int32 `xorm:"int(11) 'timeInt32' not null"` + TimeInt32Nullable *int32 `xorm:"int(11) 'timeInt32Nullable' null"` + TimeFloat32 float32 `xorm:"double 'timeFloat32' not null"` + TimeFloat32Nullable *float32 `xorm:"double 'timeFloat32Nullable' null"` + Measurement string + ValueOne int64 `xorm:"integer 'valueOne'"` + ValueTwo int64 `xorm:"integer 'valueTwo'"` } if exist, err := sess.IsTableExist(metric_values{}); err != nil || exist { @@ -298,26 +315,219 @@ func TestPostgres(t *testing.T) { return rand.Int63n(max-min) + min } + var tInitial time.Time + 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 i, t := range genTimeRangeByInterval(fromStart.Add(-30*time.Minute), 90*time.Minute, 5*time.Minute) { + if i == 0 { + tInitial = t + } + tSeconds := t.Unix() + tSecondsInt32 := int32(tSeconds) + tSecondsFloat32 := float32(tSeconds) + tMilliseconds := tSeconds * 1e3 + tMillisecondsFloat := float64(tMilliseconds) + first := metric_values{ + Time: t, + TimeInt64: tMilliseconds, + TimeInt64Nullable: &(tMilliseconds), + TimeFloat64: tMillisecondsFloat, + TimeFloat64Nullable: &tMillisecondsFloat, + TimeInt32: tSecondsInt32, + TimeInt32Nullable: &tSecondsInt32, + TimeFloat32: tSecondsFloat32, + TimeFloat32Nullable: &tSecondsFloat32, + Measurement: "Metric A", + ValueOne: rnd(0, 100), + ValueTwo: rnd(0, 100), + } + second := first + second.Measurement = "Metric B" + second.ValueOne = rnd(0, 100) + second.ValueTwo = rnd(0, 100) + + series = append(series, &first) + series = append(series, &second) } - for _, s := range series { - _, err := sess.Insert(s) + _, err = sess.InsertMulti(series) + So(err, ShouldBeNil) + + Convey("When doing a metric query using epoch (int64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeInt64" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeInt64Nullable" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeFloat64" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float64 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeFloat64Nullable" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeInt32" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (int32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeInt32Nullable" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(tInitial.UnixNano()/1e6)) + }) + + Convey("When doing a metric query using epoch (float32) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeFloat32" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) + + Convey("When doing a metric query using epoch (float32 nullable) as time column should return metric with time in milliseconds", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT "timeFloat32Nullable" as time, "valueOne" FROM metric_values ORDER BY time LIMIT 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, 1) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { query := &tsdb.TsdbQuery{ @@ -473,7 +683,7 @@ func TestPostgres(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(float64), ShouldEqual, float64(dt.Unix()*1000)) + So(columns[0].(float64), ShouldEqual, float64(dt.UnixNano()/1e6)) }) Convey("When doing an annotation query with a time column in epoch second format should return ms", func() { @@ -626,16 +836,16 @@ func TestPostgres(t *testing.T) { } func InitPostgresTestDB(t *testing.T) *xorm.Engine { - x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, strings.Replace(sqlutil.TestDB_Postgres.ConnStr, "dbname=grafanatest", "dbname=grafanadstest", 1)) + if err != nil { + t.Fatalf("Failed to init postgres db %v", err) + } + x.DatabaseTZ = time.UTC x.TZLocation = time.UTC // x.ShowSQL() - if err != nil { - t.Fatalf("Failed to init postgres db %v", err) - } - return x } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 1186fccbbf9..bf9fe9f152c 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -108,8 +108,8 @@ func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSourc span, ctx := opentracing.StartSpanFromContext(ctx, "alerting.prometheus") span.SetTag("expr", query.Expr) - span.SetTag("start_unixnano", int64(query.Start.UnixNano())) - span.SetTag("stop_unixnano", int64(query.End.UnixNano())) + span.SetTag("start_unixnano", query.Start.UnixNano()) + span.SetTag("stop_unixnano", query.End.UnixNano()) defer span.Finish() value, err := client.QueryRange(ctx, query.Expr, timeRange) diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go index 16370a4ea7f..56ed2cd3cb6 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sql_engine.go @@ -51,7 +51,7 @@ func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSour defer engineCache.Unlock() if engine, present := engineCache.cache[dsInfo.Id]; present { - if version, _ := engineCache.versions[dsInfo.Id]; version == dsInfo.Version { + if version := engineCache.versions[dsInfo.Id]; version == dsInfo.Version { e.XormEngine = engine return nil } @@ -135,16 +135,16 @@ func (e *DefaultSqlEngine) Query( return result, nil } -// ConvertTimeColumnToEpochMs converts column named time to unix timestamp in milliseconds +// ConvertSqlTimeColumnToEpochMs converts column named time to unix timestamp in milliseconds // to make native datetime types and epoch dates work in annotation and table queries. func ConvertSqlTimeColumnToEpochMs(values RowValues, timeIndex int) { if timeIndex >= 0 { switch value := values[timeIndex].(type) { case time.Time: - values[timeIndex] = EpochPrecisionToMs(float64(value.Unix())) + values[timeIndex] = EpochPrecisionToMs(float64(value.UnixNano())) case *time.Time: if value != nil { - values[timeIndex] = EpochPrecisionToMs(float64((*value).Unix())) + values[timeIndex] = EpochPrecisionToMs(float64((*value).UnixNano())) } case int64: values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) @@ -152,12 +152,36 @@ func ConvertSqlTimeColumnToEpochMs(values RowValues, timeIndex int) { if value != nil { values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) } + case uint64: + values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + case *uint64: + if value != nil { + values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + } + case int32: + values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + case *int32: + if value != nil { + values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + } + case uint32: + values[timeIndex] = int64(EpochPrecisionToMs(float64(value))) + case *uint32: + if value != nil { + values[timeIndex] = int64(EpochPrecisionToMs(float64(*value))) + } case float64: values[timeIndex] = EpochPrecisionToMs(value) case *float64: if value != nil { values[timeIndex] = EpochPrecisionToMs(*value) } + case float32: + values[timeIndex] = EpochPrecisionToMs(float64(value)) + case *float32: + if value != nil { + values[timeIndex] = EpochPrecisionToMs(float64(*value)) + } } } } diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sql_engine_test.go index 48aac2c4d45..4c6951a0196 100644 --- a/pkg/tsdb/sql_engine_test.go +++ b/pkg/tsdb/sql_engine_test.go @@ -1,6 +1,7 @@ package tsdb import ( + "fmt" "testing" "time" @@ -9,37 +10,177 @@ import ( 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()) + dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) - var nilDt *time.Time - var nilInt64 *int64 - var nilFloat64 *float64 - fixtures[5] = nilDt - fixtures[6] = nilInt64 - fixtures[7] = nilFloat64 + Convey("Given row values with time.Time as time columns", func() { + var nilPointer *time.Time + + fixtures := make([]interface{}, 3) + fixtures[0] = dt + fixtures[1] = &dt + fixtures[2] = nilPointer for i := range fixtures { ConvertSqlTimeColumnToEpochMs(fixtures, i) } - Convey("Should convert sql time columns to epoch time in ms ", func() { - expected := float64(dt.Unix() * 1000) + Convey("When converting them should return epoch time with millisecond precision ", func() { + expected := float64(dt.UnixNano()) / float64(time.Millisecond) 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[1].(float64), ShouldEqual, expected) + So(fixtures[2], ShouldBeNil) + }) + }) - So(fixtures[5], ShouldBeNil) + Convey("Given row values with int64 as time columns", func() { + tSeconds := dt.Unix() + tMilliseconds := dt.UnixNano() / 1e6 + tNanoSeconds := dt.UnixNano() + var nilPointer *int64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(int64), ShouldEqual, tSeconds*1e3) + So(fixtures[1].(int64), ShouldEqual, tSeconds*1e3) + So(fixtures[2].(int64), ShouldEqual, tMilliseconds) + So(fixtures[3].(int64), ShouldEqual, tMilliseconds) + So(fixtures[4].(int64), ShouldEqual, tMilliseconds) + So(fixtures[5].(int64), ShouldEqual, tMilliseconds) So(fixtures[6], ShouldBeNil) - So(fixtures[7], ShouldBeNil) + }) + }) + + Convey("Given row values with uin64 as time columns", func() { + tSeconds := uint64(dt.Unix()) + tMilliseconds := uint64(dt.UnixNano() / 1e6) + tNanoSeconds := uint64(dt.UnixNano()) + var nilPointer *uint64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(int64), ShouldEqual, tSeconds*1e3) + So(fixtures[1].(int64), ShouldEqual, tSeconds*1e3) + So(fixtures[2].(int64), ShouldEqual, tMilliseconds) + So(fixtures[3].(int64), ShouldEqual, tMilliseconds) + So(fixtures[4].(int64), ShouldEqual, tMilliseconds) + So(fixtures[5].(int64), ShouldEqual, tMilliseconds) + So(fixtures[6], ShouldBeNil) + }) + }) + + Convey("Given row values with int32 as time columns", func() { + tSeconds := int32(dt.Unix()) + var nilInt *int32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(int64), ShouldEqual, dt.Unix()*1e3) + So(fixtures[1].(int64), ShouldEqual, dt.Unix()*1e3) + So(fixtures[2], ShouldBeNil) + }) + }) + + Convey("Given row values with uint32 as time columns", func() { + tSeconds := uint32(dt.Unix()) + var nilInt *uint32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(int64), ShouldEqual, dt.Unix()*1e3) + So(fixtures[1].(int64), ShouldEqual, dt.Unix()*1e3) + So(fixtures[2], ShouldBeNil) + }) + }) + + Convey("Given row values with float64 as time columns", func() { + tSeconds := float64(dt.UnixNano()) / float64(time.Second) + tMilliseconds := float64(dt.UnixNano()) / float64(time.Millisecond) + tNanoSeconds := float64(dt.UnixNano()) + var nilPointer *float64 + + fixtures := make([]interface{}, 7) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = tMilliseconds + fixtures[3] = &tMilliseconds + fixtures[4] = tNanoSeconds + fixtures[5] = &tNanoSeconds + fixtures[6] = nilPointer + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(float64), ShouldEqual, tMilliseconds) + So(fixtures[1].(float64), ShouldEqual, tMilliseconds) + So(fixtures[2].(float64), ShouldEqual, tMilliseconds) + So(fixtures[3].(float64), ShouldEqual, tMilliseconds) + fmt.Println(fixtures[4].(float64)) + fmt.Println(tMilliseconds) + So(fixtures[4].(float64), ShouldEqual, tMilliseconds) + So(fixtures[5].(float64), ShouldEqual, tMilliseconds) + So(fixtures[6], ShouldBeNil) + }) + }) + + Convey("Given row values with float32 as time columns", func() { + tSeconds := float32(dt.Unix()) + var nilInt *float32 + + fixtures := make([]interface{}, 3) + fixtures[0] = tSeconds + fixtures[1] = &tSeconds + fixtures[2] = nilInt + + for i := range fixtures { + ConvertSqlTimeColumnToEpochMs(fixtures, i) + } + + Convey("When converting them should return epoch time with millisecond precision ", func() { + So(fixtures[0].(float64), ShouldEqual, float32(dt.Unix()*1e3)) + So(fixtures[1].(float64), ShouldEqual, float32(dt.Unix()*1e3)) + So(fixtures[2], ShouldBeNil) }) }) }) diff --git a/pkg/tsdb/time_range.go b/pkg/tsdb/time_range.go index fd0cb3f8e82..777fd15907e 100644 --- a/pkg/tsdb/time_range.go +++ b/pkg/tsdb/time_range.go @@ -15,6 +15,14 @@ func NewTimeRange(from, to string) *TimeRange { } } +func NewFakeTimeRange(from, to string, now time.Time) *TimeRange { + return &TimeRange{ + From: from, + To: to, + now: now, + } +} + type TimeRange struct { From string To string @@ -25,10 +33,26 @@ func (tr *TimeRange) GetFromAsMsEpoch() int64 { return tr.MustGetFrom().UnixNano() / int64(time.Millisecond) } +func (tr *TimeRange) GetFromAsSecondsEpoch() int64 { + return tr.GetFromAsMsEpoch() / 1000 +} + +func (tr *TimeRange) GetFromAsTimeUTC() time.Time { + return tr.MustGetFrom().UTC() +} + func (tr *TimeRange) GetToAsMsEpoch() int64 { return tr.MustGetTo().UnixNano() / int64(time.Millisecond) } +func (tr *TimeRange) GetToAsSecondsEpoch() int64 { + return tr.GetToAsMsEpoch() / 1000 +} + +func (tr *TimeRange) GetToAsTimeUTC() time.Time { + return tr.MustGetTo().UTC() +} + func (tr *TimeRange) MustGetFrom() time.Time { if res, err := tr.ParseFrom(); err != nil { return time.Unix(0, 0) @@ -92,9 +116,14 @@ func (tr *TimeRange) ParseTo() (time.Time, error) { // 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) + s := strconv.FormatFloat(value, 'e', -1, 64) + if strings.HasSuffix(s, "e+09") { + return value * float64(1e3) } - return float64(value) + if strings.HasSuffix(s, "e+18") { + return value / float64(time.Millisecond) + } + + return value } diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go index d87b6f70fe6..f900cb8275e 100644 --- a/pkg/util/shortid_generator.go +++ b/pkg/util/shortid_generator.go @@ -17,11 +17,7 @@ func init() { // IsValidShortUid checks if short unique identifier contains valid characters func IsValidShortUid(uid string) bool { - if !validUidPattern(uid) { - return false - } - - return true + return validUidPattern(uid) } // GenerateShortUid generates a short unique identifier. diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx index 07ccfdbbef5..4dcd07ffb48 100644 --- a/public/app/core/components/Permissions/AddPermissions.tsx +++ b/public/app/core/components/Permissions/AddPermissions.tsx @@ -39,7 +39,7 @@ class AddPermissions extends Component { permissions.newItem.setUser(null, null); return; } - return permissions.newItem.setUser(user.id, user.login); + return permissions.newItem.setUser(user.id, user.login, user.avatarUrl); } teamPicked(team: Team) { @@ -48,7 +48,7 @@ class AddPermissions extends Component { permissions.newItem.setTeam(null, null); return; } - return permissions.newItem.setTeam(team.id, team.name); + return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl); } permissionPicked(permission: OptionWithDescription) { diff --git a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx index db45714136e..5e2497d983e 100644 --- a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx +++ b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component } from 'react'; import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; @@ -12,9 +12,12 @@ export default class DisabledPermissionListItem extends Component { return ( - - - + + + + + {item.name} + (Role) Can diff --git a/public/app/core/components/Permissions/Permissions.tsx b/public/app/core/components/Permissions/Permissions.tsx index 0a0572ed86e..dbdc1682f6b 100644 --- a/public/app/core/components/Permissions/Permissions.tsx +++ b/public/app/core/components/Permissions/Permissions.tsx @@ -15,9 +15,8 @@ export interface DashboardAcl { permissionName?: string; role?: string; icon?: string; - nameHtml?: string; + name?: string; inherited?: boolean; - sortName?: string; sortRank?: number; } diff --git a/public/app/core/components/Permissions/PermissionsList.tsx b/public/app/core/components/Permissions/PermissionsList.tsx index b215dad2391..a77235ecc30 100644 --- a/public/app/core/components/Permissions/PermissionsList.tsx +++ b/public/app/core/components/Permissions/PermissionsList.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component } from 'react'; import PermissionsListItem from './PermissionsListItem'; import DisabledPermissionsListItem from './DisabledPermissionsListItem'; import { observer } from 'mobx-react'; @@ -23,7 +23,7 @@ class PermissionsList extends Component { Admin Role', + name: 'Admin', permission: 4, icon: 'fa fa-fw fa-street-view', }} diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx index 3140b8fcc0c..ee1108a6998 100644 --- a/public/app/core/components/Permissions/PermissionsListItem.tsx +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react'; import { observer } from 'mobx-react'; import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; @@ -7,6 +7,30 @@ const setClassNameHelper = inherited => { return inherited ? 'gf-form-disabled' : ''; }; +function ItemAvatar({ item }) { + if (item.userAvatarUrl) { + return ; + } + if (item.teamAvatarUrl) { + return ; + } + if (item.role === 'Editor') { + return ; + } + + return ; +} + +function ItemDescription({ item }) { + if (item.userId) { + return (User); + } + if (item.teamId) { + return (Team); + } + return (Role); +} + export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => { const handleRemoveItem = evt => { evt.preventDefault(); @@ -21,9 +45,11 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde return ( - - - + + + + + {item.name} {item.inherited && diff --git a/public/app/core/components/ScrollBar/ScrollBar.tsx b/public/app/core/components/ScrollBar/ScrollBar.tsx index 7d9e015df94..24d17f67367 100644 --- a/public/app/core/components/ScrollBar/ScrollBar.tsx +++ b/public/app/core/components/ScrollBar/ScrollBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import PerfectScrollbar from 'perfect-scrollbar'; +import baron from 'baron'; export interface Props { children: any; @@ -8,31 +8,36 @@ export interface Props { export default class ScrollBar extends React.Component { private container: any; - private ps: PerfectScrollbar; + private scrollbar: baron; constructor(props) { super(props); } componentDidMount() { - this.ps = new PerfectScrollbar(this.container, { - wheelPropagation: true, + this.scrollbar = baron({ + root: this.container.parentElement, + scroller: this.container, + bar: '.baron__bar', + barOnCls: '_scrollbar', + scrollingCls: '_scrolling', + track: '.baron__track', }); } componentDidUpdate() { - this.ps.update(); + this.scrollbar.update(); } componentWillUnmount() { - this.ps.destroy(); + this.scrollbar.dispose(); } // methods can be invoked by outside setScrollTop(top) { if (this.container) { this.container.scrollTop = top; - this.ps.update(); + this.scrollbar.update(); return true; } @@ -42,21 +47,31 @@ export default class ScrollBar extends React.Component { setScrollLeft(left) { if (this.container) { this.container.scrollLeft = left; - this.ps.update(); + this.scrollbar.update(); return true; } return false; } + update() { + this.scrollbar.update(); + } + handleRef = ref => { this.container = ref; }; render() { return ( -
- {this.props.children} +
+
+ {this.props.children} +
+ +
+
+
); } diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 798a40cb1bf..4f4b3a64fa5 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -117,6 +117,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop appEvents.emit('toggle-kiosk-mode'); } + // check for 'inactive' url param for clean looks like kiosk, but with title + if (data.params.inactive) { + body.addClass('user-activity-low'); + + // for some reason, with this class it looks cleanest + body.addClass('sidemenu-open'); + } + // close all drops for (let drop of Drop.drops) { drop.destroy(); @@ -167,6 +175,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop if (sidemenuHidden) { sidemenuHidden = false; body.addClass('sidemenu-open'); + appEvents.emit('toggle-inactive-mode'); $timeout(function() { $rootScope.$broadcast('render'); }, 100); diff --git a/public/app/core/components/json_explorer/helpers.ts b/public/app/core/components/json_explorer/helpers.ts index 5b053792d73..c445e1b0667 100644 --- a/public/app/core/components/json_explorer/helpers.ts +++ b/public/app/core/components/json_explorer/helpers.ts @@ -2,7 +2,7 @@ // Licence MIT, Copyright (c) 2015 Mohsen Azimi /* - * Escapes `"` charachters from string + * Escapes `"` characters from string */ function escapeString(str: string): string { return str.replace('"', '"'); @@ -100,7 +100,7 @@ export function cssClass(className: string): string { } /* - * Creates a new DOM element wiht given type and class + * Creates a new DOM element with given type and class * TODO: move me to helpers */ export function createElement(type: string, className?: string, content?: Element | string): Element { diff --git a/public/app/core/components/json_explorer/json_explorer.ts b/public/app/core/components/json_explorer/json_explorer.ts index 9cc1b53bc82..790ed442d5c 100644 --- a/public/app/core/components/json_explorer/json_explorer.ts +++ b/public/app/core/components/json_explorer/json_explorer.ts @@ -146,7 +146,7 @@ export class JsonExplorer { } /* - * did we recieve a key argument? + * did we receive a key argument? * This means that the formatter was called as a sub formatter of a parent formatter */ private get hasKey(): boolean { diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts new file mode 100644 index 00000000000..e6db344a4d6 --- /dev/null +++ b/public/app/core/components/scroll/page_scroll.ts @@ -0,0 +1,41 @@ +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; + +export function pageScrollbar() { + return { + restrict: 'A', + link: function(scope, elem, attrs) { + let lastPos = 0; + + appEvents.on( + 'dash-scroll', + evt => { + if (evt.restore) { + elem[0].scrollTop = lastPos; + return; + } + + lastPos = elem[0].scrollTop; + + if (evt.animate) { + elem.animate({ scrollTop: evt.pos }, 500); + } else { + elem[0].scrollTop = evt.pos; + } + }, + scope + ); + + scope.$on('$routeChangeSuccess', () => { + lastPos = 0; + elem[0].scrollTop = 0; + elem[0].focus(); + }); + + elem[0].tabIndex = -1; + elem[0].focus(); + }, + }; +} + +coreModule.directive('pageScrollbar', pageScrollbar); diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts index fbf5fd6cd37..3f9865e6dce 100644 --- a/public/app/core/components/scroll/scroll.ts +++ b/public/app/core/components/scroll/scroll.ts @@ -1,15 +1,44 @@ -import PerfectScrollbar from 'perfect-scrollbar'; +import $ from 'jquery'; +import baron from 'baron'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; +const scrollBarHTML = ` +
+
+
+`; + +const scrollRootClass = 'baron baron__root'; +const scrollerClass = 'baron__scroller'; + export function geminiScrollbar() { return { restrict: 'A', link: function(scope, elem, attrs) { - let scrollbar = new PerfectScrollbar(elem[0], { - wheelPropagation: true, - wheelSpeed: 3, - }); + let scrollRoot = elem.parent(); + let scroller = elem; + + if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') { + scrollRoot = scroller; + } + + scrollRoot.addClass(scrollRootClass); + $(scrollBarHTML).appendTo(scrollRoot); + elem.addClass(scrollerClass); + + let scrollParams = { + root: scrollRoot[0], + scroller: scroller[0], + bar: '.baron__bar', + barOnCls: '_scrollbar', + scrollingCls: '_scrolling', + track: '.baron__track', + direction: 'v', + }; + + let scrollbar = baron(scrollParams); + let lastPos = 0; appEvents.on( @@ -31,13 +60,24 @@ export function geminiScrollbar() { scope ); + // force updating dashboard width + appEvents.on('toggle-sidemenu', forceUpdate, scope); + appEvents.on('toggle-sidemenu-hidden', forceUpdate, scope); + appEvents.on('toggle-view-mode', forceUpdate, scope); + appEvents.on('toggle-kiosk-mode', forceUpdate, scope); + appEvents.on('toggle-inactive-mode', forceUpdate, scope); + + function forceUpdate() { + scrollbar.scroll(); + } + scope.$on('$routeChangeSuccess', () => { lastPos = 0; elem[0].scrollTop = 0; }); scope.$on('$destroy', () => { - scrollbar.destroy(); + scrollbar.dispose(); }); }, }; diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index acaf0730a6b..afb9e723cad 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -19,6 +19,7 @@
+
No dashboards matching your query were found.
+
diff --git a/public/app/core/components/sidemenu/sidemenu.html b/public/app/core/components/sidemenu/sidemenu.html index 1b301363e62..3a4ce11333e 100644 --- a/public/app/core/components/sidemenu/sidemenu.html +++ b/public/app/core/components/sidemenu/sidemenu.html @@ -37,7 +37,7 @@ - +
-
+
\ No newline at end of file diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 353d8762a9a..fb7021fe883 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -47,6 +47,7 @@ import { NavModelSrv, NavModel } from './nav_model_srv'; import { userPicker } from './components/user_picker'; import { teamPicker } from './components/team_picker'; import { geminiScrollbar } from './components/scroll/scroll'; +import { pageScrollbar } from './components/scroll/page_scroll'; import { gfPageDirective } from './components/gf_page'; import { orgSwitcher } from './components/org_switcher'; import { profiler } from './profiler'; @@ -85,6 +86,7 @@ export { userPicker, teamPicker, geminiScrollbar, + pageScrollbar, gfPageDirective, orgSwitcher, manageDashboardsDirective, diff --git a/public/app/core/directives/dropdown_typeahead.js b/public/app/core/directives/dropdown_typeahead.js deleted file mode 100644 index 25772b4638a..00000000000 --- a/public/app/core/directives/dropdown_typeahead.js +++ /dev/null @@ -1,236 +0,0 @@ -define([ - 'lodash', - 'jquery', - '../core_module', -], -function (_, $, coreModule) { - 'use strict'; - - coreModule.default.directive('dropdownTypeahead', function($compile) { - - var inputTemplate = ''; - - var buttonTemplate = ''; - - return { - scope: { - menuItems: "=dropdownTypeahead", - dropdownTypeaheadOnSelect: "&dropdownTypeaheadOnSelect", - model: '=ngModel' - }, - link: function($scope, elem, attrs) { - var $input = $(inputTemplate); - var $button = $(buttonTemplate); - $input.appendTo(elem); - $button.appendTo(elem); - - if (attrs.linkText) { - $button.html(attrs.linkText); - } - - if (attrs.ngModel) { - $scope.$watch('model', function(newValue) { - _.each($scope.menuItems, function(item) { - _.each(item.submenu, function(subItem) { - if (subItem.value === newValue) { - $button.html(subItem.text); - } - }); - }); - }); - } - - var typeaheadValues = _.reduce($scope.menuItems, function(memo, value, index) { - if (!value.submenu) { - value.click = 'menuItemSelected(' + index + ')'; - memo.push(value.text); - } else { - _.each(value.submenu, function(item, subIndex) { - item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; - memo.push(value.text + ' ' + item.text); - }); - } - return memo; - }, []); - - $scope.menuItemSelected = function(index, subIndex) { - var menuItem = $scope.menuItems[index]; - var payload = {$item: menuItem}; - if (menuItem.submenu && subIndex !== void 0) { - payload.$subItem = menuItem.submenu[subIndex]; - } - $scope.dropdownTypeaheadOnSelect(payload); - }; - - $input.attr('data-provide', 'typeahead'); - $input.typeahead({ - source: typeaheadValues, - minLength: 1, - items: 10, - updater: function (value) { - var result = {}; - _.each($scope.menuItems, function(menuItem) { - _.each(menuItem.submenu, function(submenuItem) { - if (value === (menuItem.text + ' ' + submenuItem.text)) { - result.$subItem = submenuItem; - result.$item = menuItem; - } - }); - }); - - if (result.$item) { - $scope.$apply(function() { - $scope.dropdownTypeaheadOnSelect(result); - }); - } - - $input.trigger('blur'); - return ''; - } - }); - - $button.click(function() { - $button.hide(); - $input.show(); - $input.focus(); - }); - - $input.keyup(function() { - elem.toggleClass('open', $input.val() === ''); - }); - - $input.blur(function() { - $input.hide(); - $input.val(''); - $button.show(); - $button.focus(); - // clicking the function dropdown menu wont - // work if you remove class at once - setTimeout(function() { - elem.removeClass('open'); - }, 200); - }); - - $compile(elem.contents())($scope); - } - }; - }); - - coreModule.default.directive('dropdownTypeahead2', function($compile) { - - var inputTemplate = ''; - - var buttonTemplate = ''; - - return { - scope: { - menuItems: "=dropdownTypeahead2", - dropdownTypeaheadOnSelect: "&dropdownTypeaheadOnSelect", - model: '=ngModel' - }, - link: function($scope, elem, attrs) { - var $input = $(inputTemplate); - var $button = $(buttonTemplate); - $input.appendTo(elem); - $button.appendTo(elem); - - if (attrs.linkText) { - $button.html(attrs.linkText); - } - - if (attrs.ngModel) { - $scope.$watch('model', function(newValue) { - _.each($scope.menuItems, function(item) { - _.each(item.submenu, function(subItem) { - if (subItem.value === newValue) { - $button.html(subItem.text); - } - }); - }); - }); - } - - var typeaheadValues = _.reduce($scope.menuItems, function(memo, value, index) { - if (!value.submenu) { - value.click = 'menuItemSelected(' + index + ')'; - memo.push(value.text); - } else { - _.each(value.submenu, function(item, subIndex) { - item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; - memo.push(value.text + ' ' + item.text); - }); - } - return memo; - }, []); - - $scope.menuItemSelected = function(index, subIndex) { - var menuItem = $scope.menuItems[index]; - var payload = {$item: menuItem}; - if (menuItem.submenu && subIndex !== void 0) { - payload.$subItem = menuItem.submenu[subIndex]; - } - $scope.dropdownTypeaheadOnSelect(payload); - }; - - $input.attr('data-provide', 'typeahead'); - $input.typeahead({ - source: typeaheadValues, - minLength: 1, - items: 10, - updater: function (value) { - var result = {}; - _.each($scope.menuItems, function(menuItem) { - _.each(menuItem.submenu, function(submenuItem) { - if (value === (menuItem.text + ' ' + submenuItem.text)) { - result.$subItem = submenuItem; - result.$item = menuItem; - } - }); - }); - - if (result.$item) { - $scope.$apply(function() { - $scope.dropdownTypeaheadOnSelect(result); - }); - } - - $input.trigger('blur'); - return ''; - } - }); - - $button.click(function() { - $button.hide(); - $input.show(); - $input.focus(); - }); - - $input.keyup(function() { - elem.toggleClass('open', $input.val() === ''); - }); - - $input.blur(function() { - $input.hide(); - $input.val(''); - $button.show(); - $button.focus(); - // clicking the function dropdown menu wont - // work if you remove class at once - setTimeout(function() { - elem.removeClass('open'); - }, 200); - }); - - $compile(elem.contents())($scope); - } - }; - }); -}); diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts new file mode 100644 index 00000000000..c9e44c5e786 --- /dev/null +++ b/public/app/core/directives/dropdown_typeahead.ts @@ -0,0 +1,244 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from '../core_module'; + +/** @ngInject */ +export function dropdownTypeahead($compile) { + let inputTemplate = + ''; + + let buttonTemplate = + ''; + + return { + scope: { + menuItems: '=dropdownTypeahead', + dropdownTypeaheadOnSelect: '&dropdownTypeaheadOnSelect', + model: '=ngModel', + }, + link: function($scope, elem, attrs) { + let $input = $(inputTemplate); + let $button = $(buttonTemplate); + $input.appendTo(elem); + $button.appendTo(elem); + + if (attrs.linkText) { + $button.html(attrs.linkText); + } + + if (attrs.ngModel) { + $scope.$watch('model', function(newValue) { + _.each($scope.menuItems, function(item) { + _.each(item.submenu, function(subItem) { + if (subItem.value === newValue) { + $button.html(subItem.text); + } + }); + }); + }); + } + + let typeaheadValues = _.reduce( + $scope.menuItems, + function(memo, value, index) { + if (!value.submenu) { + value.click = 'menuItemSelected(' + index + ')'; + memo.push(value.text); + } else { + _.each(value.submenu, function(item, subIndex) { + item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; + memo.push(value.text + ' ' + item.text); + }); + } + return memo; + }, + [] + ); + + $scope.menuItemSelected = function(index, subIndex) { + let menuItem = $scope.menuItems[index]; + let payload: any = { $item: menuItem }; + if (menuItem.submenu && subIndex !== void 0) { + payload.$subItem = menuItem.submenu[subIndex]; + } + $scope.dropdownTypeaheadOnSelect(payload); + }; + + $input.attr('data-provide', 'typeahead'); + $input.typeahead({ + source: typeaheadValues, + minLength: 1, + items: 10, + updater: function(value) { + let result: any = {}; + _.each($scope.menuItems, function(menuItem) { + _.each(menuItem.submenu, function(submenuItem) { + if (value === menuItem.text + ' ' + submenuItem.text) { + result.$subItem = submenuItem; + result.$item = menuItem; + } + }); + }); + + if (result.$item) { + $scope.$apply(function() { + $scope.dropdownTypeaheadOnSelect(result); + }); + } + + $input.trigger('blur'); + return ''; + }, + }); + + $button.click(function() { + $button.hide(); + $input.show(); + $input.focus(); + }); + + $input.keyup(function() { + elem.toggleClass('open', $input.val() === ''); + }); + + $input.blur(function() { + $input.hide(); + $input.val(''); + $button.show(); + $button.focus(); + // clicking the function dropdown menu won't + // work if you remove class at once + setTimeout(function() { + elem.removeClass('open'); + }, 200); + }); + + $compile(elem.contents())($scope); + }, + }; +} + +/** @ngInject */ +export function dropdownTypeahead2($compile) { + let inputTemplate = + ''; + + let buttonTemplate = + ''; + + return { + scope: { + menuItems: '=dropdownTypeahead2', + dropdownTypeaheadOnSelect: '&dropdownTypeaheadOnSelect', + model: '=ngModel', + }, + link: function($scope, elem, attrs) { + let $input = $(inputTemplate); + let $button = $(buttonTemplate); + $input.appendTo(elem); + $button.appendTo(elem); + + if (attrs.linkText) { + $button.html(attrs.linkText); + } + + if (attrs.ngModel) { + $scope.$watch('model', function(newValue) { + _.each($scope.menuItems, function(item) { + _.each(item.submenu, function(subItem) { + if (subItem.value === newValue) { + $button.html(subItem.text); + } + }); + }); + }); + } + + let typeaheadValues = _.reduce( + $scope.menuItems, + function(memo, value, index) { + if (!value.submenu) { + value.click = 'menuItemSelected(' + index + ')'; + memo.push(value.text); + } else { + _.each(value.submenu, function(item, subIndex) { + item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; + memo.push(value.text + ' ' + item.text); + }); + } + return memo; + }, + [] + ); + + $scope.menuItemSelected = function(index, subIndex) { + let menuItem = $scope.menuItems[index]; + let payload: any = { $item: menuItem }; + if (menuItem.submenu && subIndex !== void 0) { + payload.$subItem = menuItem.submenu[subIndex]; + } + $scope.dropdownTypeaheadOnSelect(payload); + }; + + $input.attr('data-provide', 'typeahead'); + $input.typeahead({ + source: typeaheadValues, + minLength: 1, + items: 10, + updater: function(value) { + let result: any = {}; + _.each($scope.menuItems, function(menuItem) { + _.each(menuItem.submenu, function(submenuItem) { + if (value === menuItem.text + ' ' + submenuItem.text) { + result.$subItem = submenuItem; + result.$item = menuItem; + } + }); + }); + + if (result.$item) { + $scope.$apply(function() { + $scope.dropdownTypeaheadOnSelect(result); + }); + } + + $input.trigger('blur'); + return ''; + }, + }); + + $button.click(function() { + $button.hide(); + $input.show(); + $input.focus(); + }); + + $input.keyup(function() { + elem.toggleClass('open', $input.val() === ''); + }); + + $input.blur(function() { + $input.hide(); + $input.val(''); + $button.show(); + $button.focus(); + // clicking the function dropdown menu won't + // work if you remove class at once + setTimeout(function() { + elem.removeClass('open'); + }, 200); + }); + + $compile(elem.contents())($scope); + }, + }; +} + +coreModule.directive('dropdownTypeahead', dropdownTypeahead); +coreModule.directive('dropdownTypeahead2', dropdownTypeahead2); diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js deleted file mode 100644 index 7ba4a5a5259..00000000000 --- a/public/app/core/directives/metric_segment.js +++ /dev/null @@ -1,246 +0,0 @@ -define([ - 'lodash', - 'jquery', - '../core_module', -], -function (_, $, coreModule) { - 'use strict'; - - coreModule.default.directive('metricSegment', function($compile, $sce) { - var inputTemplate = ''; - - var linkTemplate = ''; - - var selectTemplate = ''; - - return { - scope: { - segment: "=", - getOptions: "&", - onChange: "&", - debounce: "@", - }, - link: function($scope, elem) { - var $input = $(inputTemplate); - var segment = $scope.segment; - var $button = $(segment.selectMode ? selectTemplate : linkTemplate); - var options = null; - var cancelBlur = null; - var linkMode = true; - var debounceLookup = $scope.debounce; - - $input.appendTo(elem); - $button.appendTo(elem); - - $scope.updateVariableValue = function(value) { - if (value === '' || segment.value === value) { - return; - } - - value = _.unescape(value); - - $scope.$apply(function() { - var selected = _.find($scope.altSegments, {value: value}); - if (selected) { - segment.value = selected.value; - segment.html = selected.html || selected.value; - segment.fake = false; - segment.expandable = selected.expandable; - - if (selected.type) { - segment.type = selected.type; - } - } - else if (segment.custom !== 'false') { - segment.value = value; - segment.html = $sce.trustAsHtml(value); - segment.expandable = true; - segment.fake = false; - } - - $scope.onChange(); - }); - }; - - $scope.switchToLink = function(fromClick) { - if (linkMode && !fromClick) { return; } - - clearTimeout(cancelBlur); - cancelBlur = null; - linkMode = true; - $input.hide(); - $button.show(); - $scope.updateVariableValue($input.val()); - }; - - $scope.inputBlur = function() { - // happens long before the click event on the typeahead options - // need to have long delay because the blur - cancelBlur = setTimeout($scope.switchToLink, 200); - }; - - $scope.source = function(query, callback) { - $scope.$apply(function() { - $scope.getOptions({ $query: query }).then(function(altSegments) { - $scope.altSegments = altSegments; - options = _.map($scope.altSegments, function(alt) { - return _.escape(alt.value); - }); - - // add custom values - if (segment.custom !== 'false') { - if (!segment.fake && _.indexOf(options, segment.value) === -1) { - options.unshift(segment.value); - } - } - - callback(options); - }); - }); - }; - - $scope.updater = function(value) { - if (value === segment.value) { - clearTimeout(cancelBlur); - $input.focus(); - return value; - } - - $input.val(value); - $scope.switchToLink(true); - - return value; - }; - - $scope.matcher = function(item) { - var str = this.query; - if (str[0] === '/') { str = str.substring(1); } - if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); } - try { - return item.toLowerCase().match(str.toLowerCase()); - } catch(e) { - return false; - } - }; - - $input.attr('data-provide', 'typeahead'); - $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); - - var typeahead = $input.data('typeahead'); - typeahead.lookup = function () { - this.query = this.$element.val() || ''; - var items = this.source(this.query, $.proxy(this.process, this)); - return items ? this.process(items) : items; - }; - - if (debounceLookup) { - typeahead.lookup = _.debounce(typeahead.lookup, 500, {leading: true}); - } - - $button.keydown(function(evt) { - // trigger typeahead on down arrow or enter key - if (evt.keyCode === 40 || evt.keyCode === 13) { - $button.click(); - } - }); - - $button.click(function() { - options = null; - $input.css('width', (Math.max($button.width(), 80) + 16) + 'px'); - - $button.hide(); - $input.show(); - $input.focus(); - - linkMode = false; - - var typeahead = $input.data('typeahead'); - if (typeahead) { - $input.val(''); - typeahead.lookup(); - } - }); - - $input.blur($scope.inputBlur); - - $compile(elem.contents())($scope); - } - }; - }); - - coreModule.default.directive('metricSegmentModel', function(uiSegmentSrv, $q) { - return { - template: '', - restrict: 'E', - scope: { - property: "=", - options: "=", - getOptions: "&", - onChange: "&", - }, - link: { - pre: function postLink($scope, elem, attrs) { - var cachedOptions; - - $scope.valueToSegment = function(value) { - var option = _.find($scope.options, {value: value}); - var segment = { - cssClass: attrs.cssClass, - custom: attrs.custom, - value: option ? option.text : value, - selectMode: attrs.selectMode, - }; - - return uiSegmentSrv.newSegment(segment); - }; - - $scope.getOptionsInternal = function() { - if ($scope.options) { - cachedOptions = $scope.options; - return $q.when(_.map($scope.options, function(option) { - return {value: option.text}; - })); - } else { - return $scope.getOptions().then(function(options) { - cachedOptions = options; - return _.map(options, function(option) { - if (option.html) { - return option; - } - return {value: option.text}; - }); - }); - } - }; - - $scope.onSegmentChange = function() { - if (cachedOptions) { - var option = _.find(cachedOptions, {text: $scope.segment.value}); - if (option && option.value !== $scope.property) { - $scope.property = option.value; - } else if (attrs.custom !== 'false') { - $scope.property = $scope.segment.value; - } - } else { - $scope.property = $scope.segment.value; - } - - // needs to call this after digest so - // property is synced with outerscope - $scope.$$postDigest(function() { - $scope.$apply(function() { - $scope.onChange(); - }); - }); - }; - - $scope.segment = $scope.valueToSegment($scope.property); - } - } - }; - }); -}); diff --git a/public/app/core/directives/metric_segment.ts b/public/app/core/directives/metric_segment.ts new file mode 100644 index 00000000000..3718d7fbd4a --- /dev/null +++ b/public/app/core/directives/metric_segment.ts @@ -0,0 +1,263 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from '../core_module'; + +/** @ngInject */ +export function metricSegment($compile, $sce) { + let inputTemplate = + ''; + + let linkTemplate = + ''; + + let selectTemplate = + ''; + + return { + scope: { + segment: '=', + getOptions: '&', + onChange: '&', + debounce: '@', + }, + link: function($scope, elem) { + let $input = $(inputTemplate); + let segment = $scope.segment; + let $button = $(segment.selectMode ? selectTemplate : linkTemplate); + let options = null; + let cancelBlur = null; + let linkMode = true; + let debounceLookup = $scope.debounce; + + $input.appendTo(elem); + $button.appendTo(elem); + + $scope.updateVariableValue = function(value) { + if (value === '' || segment.value === value) { + return; + } + + value = _.unescape(value); + + $scope.$apply(function() { + let selected = _.find($scope.altSegments, { value: value }); + if (selected) { + segment.value = selected.value; + segment.html = selected.html || selected.value; + segment.fake = false; + segment.expandable = selected.expandable; + + if (selected.type) { + segment.type = selected.type; + } + } else if (segment.custom !== 'false') { + segment.value = value; + segment.html = $sce.trustAsHtml(value); + segment.expandable = true; + segment.fake = false; + } + + $scope.onChange(); + }); + }; + + $scope.switchToLink = function(fromClick) { + if (linkMode && !fromClick) { + return; + } + + clearTimeout(cancelBlur); + cancelBlur = null; + linkMode = true; + $input.hide(); + $button.show(); + $scope.updateVariableValue($input.val()); + }; + + $scope.inputBlur = function() { + // happens long before the click event on the typeahead options + // need to have long delay because the blur + cancelBlur = setTimeout($scope.switchToLink, 200); + }; + + $scope.source = function(query, callback) { + $scope.$apply(function() { + $scope.getOptions({ $query: query }).then(function(altSegments) { + $scope.altSegments = altSegments; + options = _.map($scope.altSegments, function(alt) { + return _.escape(alt.value); + }); + + // add custom values + if (segment.custom !== 'false') { + if (!segment.fake && _.indexOf(options, segment.value) === -1) { + options.unshift(segment.value); + } + } + + callback(options); + }); + }); + }; + + $scope.updater = function(value) { + if (value === segment.value) { + clearTimeout(cancelBlur); + $input.focus(); + return value; + } + + $input.val(value); + $scope.switchToLink(true); + + return value; + }; + + $scope.matcher = function(item) { + let str = this.query; + if (str[0] === '/') { + str = str.substring(1); + } + if (str[str.length - 1] === '/') { + str = str.substring(0, str.length - 1); + } + try { + return item.toLowerCase().match(str.toLowerCase()); + } catch (e) { + return false; + } + }; + + $input.attr('data-provide', 'typeahead'); + $input.typeahead({ + source: $scope.source, + minLength: 0, + items: 10000, + updater: $scope.updater, + matcher: $scope.matcher, + }); + + let typeahead = $input.data('typeahead'); + typeahead.lookup = function() { + this.query = this.$element.val() || ''; + let items = this.source(this.query, $.proxy(this.process, this)); + return items ? this.process(items) : items; + }; + + if (debounceLookup) { + typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true }); + } + + $button.keydown(function(evt) { + // trigger typeahead on down arrow or enter key + if (evt.keyCode === 40 || evt.keyCode === 13) { + $button.click(); + } + }); + + $button.click(function() { + options = null; + $input.css('width', Math.max($button.width(), 80) + 16 + 'px'); + + $button.hide(); + $input.show(); + $input.focus(); + + linkMode = false; + + let typeahead = $input.data('typeahead'); + if (typeahead) { + $input.val(''); + typeahead.lookup(); + } + }); + + $input.blur($scope.inputBlur); + + $compile(elem.contents())($scope); + }, + }; +} + +/** @ngInject */ +export function metricSegmentModel(uiSegmentSrv, $q) { + return { + template: + '', + restrict: 'E', + scope: { + property: '=', + options: '=', + getOptions: '&', + onChange: '&', + }, + link: { + pre: function postLink($scope, elem, attrs) { + let cachedOptions; + + $scope.valueToSegment = function(value) { + let option = _.find($scope.options, { value: value }); + let segment = { + cssClass: attrs.cssClass, + custom: attrs.custom, + value: option ? option.text : value, + selectMode: attrs.selectMode, + }; + + return uiSegmentSrv.newSegment(segment); + }; + + $scope.getOptionsInternal = function() { + if ($scope.options) { + cachedOptions = $scope.options; + return $q.when( + _.map($scope.options, function(option) { + return { value: option.text }; + }) + ); + } else { + return $scope.getOptions().then(function(options) { + cachedOptions = options; + return _.map(options, function(option) { + if (option.html) { + return option; + } + return { value: option.text }; + }); + }); + } + }; + + $scope.onSegmentChange = function() { + if (cachedOptions) { + let option = _.find(cachedOptions, { text: $scope.segment.value }); + if (option && option.value !== $scope.property) { + $scope.property = option.value; + } else if (attrs.custom !== 'false') { + $scope.property = $scope.segment.value; + } + } else { + $scope.property = $scope.segment.value; + } + + // needs to call this after digest so + // property is synced with outerscope + $scope.$$postDigest(function() { + $scope.$apply(function() { + $scope.onChange(); + }); + }); + }; + + $scope.segment = $scope.valueToSegment($scope.property); + }, + }, + }; +} + +coreModule.directive('metricSegment', metricSegment); +coreModule.directive('metricSegmentModel', metricSegmentModel); diff --git a/public/app/core/directives/value_select_dropdown.js b/public/app/core/directives/value_select_dropdown.js deleted file mode 100644 index a2bae1c34d3..00000000000 --- a/public/app/core/directives/value_select_dropdown.js +++ /dev/null @@ -1,283 +0,0 @@ -define([ - 'angular', - 'lodash', - '../core_module', -], -function (angular, _, coreModule) { - 'use strict'; - - coreModule.default.controller('ValueSelectDropdownCtrl', function($q) { - var vm = this; - - vm.show = function() { - vm.oldVariableText = vm.variable.current.text; - vm.highlightIndex = -1; - - vm.options = vm.variable.options; - vm.selectedValues = _.filter(vm.options, {selected: true}); - - vm.tags = _.map(vm.variable.tags, function(value) { - var tag = { text: value, selected: false }; - _.each(vm.variable.current.tags, function(tagObj) { - if (tagObj.text === value) { - tag = tagObj; - } - }); - return tag; - }); - - vm.search = { - query: '', - options: vm.options.slice(0, Math.min(vm.options.length, 1000)) - }; - - vm.dropdownVisible = true; - }; - - vm.updateLinkText = function() { - var current = vm.variable.current; - - if (current.tags && current.tags.length) { - // filer out values that are in selected tags - var selectedAndNotInTag = _.filter(vm.variable.options, function(option) { - if (!option.selected) { return false; } - for (var i = 0; i < current.tags.length; i++) { - var tag = current.tags[i]; - if (_.indexOf(tag.values, option.value) !== -1) { - return false; - } - } - return true; - }); - - // convert values to text - var currentTexts = _.map(selectedAndNotInTag, 'text'); - - // join texts - vm.linkText = currentTexts.join(' + '); - if (vm.linkText.length > 0) { - vm.linkText += ' + '; - } - } else { - vm.linkText = vm.variable.current.text; - } - }; - - vm.clearSelections = function() { - _.each(vm.options, function(option) { - option.selected = false; - }); - - vm.selectionsChanged(false); - }; - - vm.selectTag = function(tag) { - tag.selected = !tag.selected; - var tagValuesPromise; - if (!tag.values) { - tagValuesPromise = vm.variable.getValuesForTag(tag.text); - } else { - tagValuesPromise = $q.when(tag.values); - } - - tagValuesPromise.then(function(values) { - tag.values = values; - tag.valuesText = values.join(' + '); - _.each(vm.options, function(option) { - if (_.indexOf(tag.values, option.value) !== -1) { - option.selected = tag.selected; - } - }); - - vm.selectionsChanged(false); - }); - }; - - vm.keyDown = function (evt) { - if (evt.keyCode === 27) { - vm.hide(); - } - if (evt.keyCode === 40) { - vm.moveHighlight(1); - } - if (evt.keyCode === 38) { - vm.moveHighlight(-1); - } - if (evt.keyCode === 13) { - if (vm.search.options.length === 0) { - vm.commitChanges(); - } else { - vm.selectValue(vm.search.options[vm.highlightIndex], {}, true, false); - } - } - if (evt.keyCode === 32) { - vm.selectValue(vm.search.options[vm.highlightIndex], {}, false, false); - } - }; - - vm.moveHighlight = function(direction) { - vm.highlightIndex = (vm.highlightIndex + direction) % vm.search.options.length; - }; - - vm.selectValue = function(option, event, commitChange, excludeOthers) { - if (!option) { return; } - - option.selected = vm.variable.multi ? !option.selected: true; - - commitChange = commitChange || false; - excludeOthers = excludeOthers || false; - - var setAllExceptCurrentTo = function(newValue) { - _.each(vm.options, function(other) { - if (option !== other) { other.selected = newValue; } - }); - }; - - // commit action (enter key), should not deselect it - if (commitChange) { - option.selected = true; - } - - if (option.text === 'All' || excludeOthers) { - setAllExceptCurrentTo(false); - commitChange = true; - } - else if (!vm.variable.multi) { - setAllExceptCurrentTo(false); - commitChange = true; - } else if (event.ctrlKey || event.metaKey || event.shiftKey) { - commitChange = true; - setAllExceptCurrentTo(false); - } - - vm.selectionsChanged(commitChange); - }; - - vm.selectionsChanged = function(commitChange) { - vm.selectedValues = _.filter(vm.options, {selected: true}); - - if (vm.selectedValues.length > 1) { - if (vm.selectedValues[0].text === 'All') { - vm.selectedValues[0].selected = false; - vm.selectedValues = vm.selectedValues.slice(1, vm.selectedValues.length); - } - } - - // validate selected tags - _.each(vm.tags, function(tag) { - if (tag.selected) { - _.each(tag.values, function(value) { - if (!_.find(vm.selectedValues, {value: value})) { - tag.selected = false; - } - }); - } - }); - - vm.selectedTags = _.filter(vm.tags, {selected: true}); - vm.variable.current.value = _.map(vm.selectedValues, 'value'); - vm.variable.current.text = _.map(vm.selectedValues, 'text').join(' + '); - vm.variable.current.tags = vm.selectedTags; - - if (!vm.variable.multi) { - vm.variable.current.value = vm.selectedValues[0].value; - } - - if (commitChange) { - vm.commitChanges(); - } - }; - - vm.commitChanges = function() { - // if we have a search query and no options use that - if (vm.search.options.length === 0 && vm.search.query.length > 0) { - vm.variable.current = {text: vm.search.query, value: vm.search.query}; - } - else if (vm.selectedValues.length === 0) { - // make sure one option is selected - vm.options[0].selected = true; - vm.selectionsChanged(false); - } - - vm.dropdownVisible = false; - vm.updateLinkText(); - - if (vm.variable.current.text !== vm.oldVariableText) { - vm.onUpdated(); - } - }; - - vm.queryChanged = function() { - vm.highlightIndex = -1; - vm.search.options = _.filter(vm.options, function(option) { - return option.text.toLowerCase().indexOf(vm.search.query.toLowerCase()) !== -1; - }); - - vm.search.options = vm.search.options.slice(0, Math.min(vm.search.options.length, 1000)); - }; - - vm.init = function() { - vm.selectedTags = vm.variable.current.tags || []; - vm.updateLinkText(); - }; - - }); - - coreModule.default.directive('valueSelectDropdown', function($compile, $window, $timeout, $rootScope) { - return { - scope: { variable: "=", onUpdated: "&"}, - templateUrl: 'public/app/partials/valueSelectDropdown.html', - controller: 'ValueSelectDropdownCtrl', - controllerAs: 'vm', - bindToController: true, - link: function(scope, elem) { - var bodyEl = angular.element($window.document.body); - var linkEl = elem.find('.variable-value-link'); - var inputEl = elem.find('input'); - - function openDropdown() { - inputEl.css('width', Math.max(linkEl.width(), 80) + 'px'); - - inputEl.show(); - linkEl.hide(); - - inputEl.focus(); - $timeout(function() { bodyEl.on('click', bodyOnClick); }, 0, false); - } - - function switchToLink() { - inputEl.hide(); - linkEl.show(); - bodyEl.off('click', bodyOnClick); - } - - function bodyOnClick (e) { - if (elem.has(e.target).length === 0) { - scope.$apply(function() { - scope.vm.commitChanges(); - }); - } - } - - scope.$watch('vm.dropdownVisible', function(newValue) { - if (newValue) { - openDropdown(); - } else { - switchToLink(); - } - }); - - var cleanUp = $rootScope.$on('template-variable-value-updated', function() { - scope.vm.updateLinkText(); - }); - - scope.$on("$destroy", function() { - cleanUp(); - }); - - scope.vm.init(); - }, - }; - }); - -}); diff --git a/public/app/core/directives/value_select_dropdown.ts b/public/app/core/directives/value_select_dropdown.ts new file mode 100644 index 00000000000..d6c6c3af5c5 --- /dev/null +++ b/public/app/core/directives/value_select_dropdown.ts @@ -0,0 +1,305 @@ +import angular from 'angular'; +import _ from 'lodash'; +import coreModule from '../core_module'; + +export class ValueSelectDropdownCtrl { + dropdownVisible: any; + highlightIndex: any; + linkText: any; + oldVariableText: any; + options: any; + search: any; + selectedTags: any; + selectedValues: any; + tags: any; + variable: any; + + hide: any; + onUpdated: any; + + /** @ngInject */ + constructor(private $q) {} + + show() { + this.oldVariableText = this.variable.current.text; + this.highlightIndex = -1; + + this.options = this.variable.options; + this.selectedValues = _.filter(this.options, { selected: true }); + + this.tags = _.map(this.variable.tags, value => { + let tag = { text: value, selected: false }; + _.each(this.variable.current.tags, tagObj => { + if (tagObj.text === value) { + tag = tagObj; + } + }); + return tag; + }); + + this.search = { + query: '', + options: this.options.slice(0, Math.min(this.options.length, 1000)), + }; + + this.dropdownVisible = true; + } + + updateLinkText() { + let current = this.variable.current; + + if (current.tags && current.tags.length) { + // filer out values that are in selected tags + let selectedAndNotInTag = _.filter(this.variable.options, option => { + if (!option.selected) { + return false; + } + for (let i = 0; i < current.tags.length; i++) { + let tag = current.tags[i]; + if (_.indexOf(tag.values, option.value) !== -1) { + return false; + } + } + return true; + }); + + // convert values to text + let currentTexts = _.map(selectedAndNotInTag, 'text'); + + // join texts + this.linkText = currentTexts.join(' + '); + if (this.linkText.length > 0) { + this.linkText += ' + '; + } + } else { + this.linkText = this.variable.current.text; + } + } + + clearSelections() { + _.each(this.options, option => { + option.selected = false; + }); + + this.selectionsChanged(false); + } + + selectTag(tag) { + tag.selected = !tag.selected; + let tagValuesPromise; + if (!tag.values) { + tagValuesPromise = this.variable.getValuesForTag(tag.text); + } else { + tagValuesPromise = this.$q.when(tag.values); + } + + tagValuesPromise.then(values => { + tag.values = values; + tag.valuesText = values.join(' + '); + _.each(this.options, option => { + if (_.indexOf(tag.values, option.value) !== -1) { + option.selected = tag.selected; + } + }); + + this.selectionsChanged(false); + }); + } + + keyDown(evt) { + if (evt.keyCode === 27) { + this.hide(); + } + if (evt.keyCode === 40) { + this.moveHighlight(1); + } + if (evt.keyCode === 38) { + this.moveHighlight(-1); + } + if (evt.keyCode === 13) { + if (this.search.options.length === 0) { + this.commitChanges(); + } else { + this.selectValue(this.search.options[this.highlightIndex], {}, true, false); + } + } + if (evt.keyCode === 32) { + this.selectValue(this.search.options[this.highlightIndex], {}, false, false); + } + } + + moveHighlight(direction) { + this.highlightIndex = (this.highlightIndex + direction) % this.search.options.length; + } + + selectValue(option, event, commitChange, excludeOthers) { + if (!option) { + return; + } + + option.selected = this.variable.multi ? !option.selected : true; + + commitChange = commitChange || false; + excludeOthers = excludeOthers || false; + + let setAllExceptCurrentTo = newValue => { + _.each(this.options, other => { + if (option !== other) { + other.selected = newValue; + } + }); + }; + + // commit action (enter key), should not deselect it + if (commitChange) { + option.selected = true; + } + + if (option.text === 'All' || excludeOthers) { + setAllExceptCurrentTo(false); + commitChange = true; + } else if (!this.variable.multi) { + setAllExceptCurrentTo(false); + commitChange = true; + } else if (event.ctrlKey || event.metaKey || event.shiftKey) { + commitChange = true; + setAllExceptCurrentTo(false); + } + + this.selectionsChanged(commitChange); + } + + selectionsChanged(commitChange) { + this.selectedValues = _.filter(this.options, { selected: true }); + + if (this.selectedValues.length > 1) { + if (this.selectedValues[0].text === 'All') { + this.selectedValues[0].selected = false; + this.selectedValues = this.selectedValues.slice(1, this.selectedValues.length); + } + } + + // validate selected tags + _.each(this.tags, tag => { + if (tag.selected) { + _.each(tag.values, value => { + if (!_.find(this.selectedValues, { value: value })) { + tag.selected = false; + } + }); + } + }); + + this.selectedTags = _.filter(this.tags, { selected: true }); + this.variable.current.value = _.map(this.selectedValues, 'value'); + this.variable.current.text = _.map(this.selectedValues, 'text').join(' + '); + this.variable.current.tags = this.selectedTags; + + if (!this.variable.multi) { + this.variable.current.value = this.selectedValues[0].value; + } + + if (commitChange) { + this.commitChanges(); + } + } + + commitChanges() { + // if we have a search query and no options use that + if (this.search.options.length === 0 && this.search.query.length > 0) { + this.variable.current = { text: this.search.query, value: this.search.query }; + } else if (this.selectedValues.length === 0) { + // make sure one option is selected + this.options[0].selected = true; + this.selectionsChanged(false); + } + + this.dropdownVisible = false; + this.updateLinkText(); + + if (this.variable.current.text !== this.oldVariableText) { + this.onUpdated(); + } + } + + queryChanged() { + this.highlightIndex = -1; + this.search.options = _.filter(this.options, option => { + return option.text.toLowerCase().indexOf(this.search.query.toLowerCase()) !== -1; + }); + + this.search.options = this.search.options.slice(0, Math.min(this.search.options.length, 1000)); + } + + init() { + this.selectedTags = this.variable.current.tags || []; + this.updateLinkText(); + } +} + +/** @ngInject */ +export function valueSelectDropdown($compile, $window, $timeout, $rootScope) { + return { + scope: { variable: '=', onUpdated: '&' }, + templateUrl: 'public/app/partials/valueSelectDropdown.html', + controller: 'ValueSelectDropdownCtrl', + controllerAs: 'vm', + bindToController: true, + link: function(scope, elem) { + let bodyEl = angular.element($window.document.body); + let linkEl = elem.find('.variable-value-link'); + let inputEl = elem.find('input'); + + function openDropdown() { + inputEl.css('width', Math.max(linkEl.width(), 80) + 'px'); + + inputEl.show(); + linkEl.hide(); + + inputEl.focus(); + $timeout( + function() { + bodyEl.on('click', bodyOnClick); + }, + 0, + false + ); + } + + function switchToLink() { + inputEl.hide(); + linkEl.show(); + bodyEl.off('click', bodyOnClick); + } + + function bodyOnClick(e) { + if (elem.has(e.target).length === 0) { + scope.$apply(function() { + scope.vm.commitChanges(); + }); + } + } + + scope.$watch('vm.dropdownVisible', newValue => { + if (newValue) { + openDropdown(); + } else { + switchToLink(); + } + }); + + let cleanUp = $rootScope.$on('template-variable-value-updated', () => { + scope.vm.updateLinkText(); + }); + + scope.$on('$destroy', () => { + cleanUp(); + }); + + scope.vm.init(); + }, + }; +} + +coreModule.controller('ValueSelectDropdownCtrl', ValueSelectDropdownCtrl); +coreModule.directive('valueSelectDropdown', valueSelectDropdown); diff --git a/public/app/core/utils/css_loader.ts b/public/app/core/utils/css_loader.ts index 42f59a9c27c..ba8623df842 100644 --- a/public/app/core/utils/css_loader.ts +++ b/public/app/core/utils/css_loader.ts @@ -67,7 +67,7 @@ export function fetch(load): any { return ''; } - // dont reload styles loaded in the head + // don't reload styles loaded in the head for (var i = 0; i < linkHrefs.length; i++) { if (load.address === linkHrefs[i]) { return ''; diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index dcb04a3e38e..8c3e3e72dda 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -620,13 +620,13 @@ kbn.valueFormats.ms = function(size, decimals, scaledDecimals) { // Less than 1 min return kbn.toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' s'); } else if (Math.abs(size) < 3600000) { - // Less than 1 hour, devide in minutes + // Less than 1 hour, divide in minutes return kbn.toFixedScaled(size / 60000, decimals, scaledDecimals, 5, ' min'); } else if (Math.abs(size) < 86400000) { - // Less than one day, devide in hours + // Less than one day, divide in hours return kbn.toFixedScaled(size / 3600000, decimals, scaledDecimals, 7, ' hour'); } else if (Math.abs(size) < 31536000000) { - // Less than one year, devide in days + // Less than one year, divide in days return kbn.toFixedScaled(size / 86400000, decimals, scaledDecimals, 8, ' day'); } @@ -638,15 +638,15 @@ kbn.valueFormats.s = function(size, decimals, scaledDecimals) { return ''; } - // Less than 1 µs, devide in ns + // Less than 1 µs, divide in ns if (Math.abs(size) < 0.000001) { return kbn.toFixedScaled(size * 1e9, decimals, scaledDecimals - decimals, -9, ' ns'); } - // Less than 1 ms, devide in µs + // Less than 1 ms, divide in µs if (Math.abs(size) < 0.001) { return kbn.toFixedScaled(size * 1e6, decimals, scaledDecimals - decimals, -6, ' µs'); } - // Less than 1 second, devide in ms + // Less than 1 second, divide in ms if (Math.abs(size) < 1) { return kbn.toFixedScaled(size * 1e3, decimals, scaledDecimals - decimals, -3, ' ms'); } @@ -654,16 +654,16 @@ kbn.valueFormats.s = function(size, decimals, scaledDecimals) { if (Math.abs(size) < 60) { return kbn.toFixed(size, decimals) + ' s'; } else if (Math.abs(size) < 3600) { - // Less than 1 hour, devide in minutes + // Less than 1 hour, divide in minutes return kbn.toFixedScaled(size / 60, decimals, scaledDecimals, 1, ' min'); } else if (Math.abs(size) < 86400) { - // Less than one day, devide in hours + // Less than one day, divide in hours return kbn.toFixedScaled(size / 3600, decimals, scaledDecimals, 4, ' hour'); } else if (Math.abs(size) < 604800) { - // Less than one week, devide in days + // Less than one week, divide in days return kbn.toFixedScaled(size / 86400, decimals, scaledDecimals, 5, ' day'); } else if (Math.abs(size) < 31536000) { - // Less than one year, devide in week + // Less than one year, divide in week return kbn.toFixedScaled(size / 604800, decimals, scaledDecimals, 6, ' week'); } diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index d86461780ba..797a67abfd8 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -124,7 +124,7 @@ function joinEvalMatches(matches, separator: string) { } function getAlertAnnotationInfo(ah) { - // backward compatability, can be removed in grafana 5.x + // backward compatibility, can be removed in grafana 5.x // old way stored evalMatches in data property directly, // new way stores it in evalMatches property on new data object diff --git a/public/app/features/alerting/specs/threshold_mapper_specs.ts b/public/app/features/alerting/specs/threshold_mapper_specs.ts index 3b284776b8d..1d68fce7050 100644 --- a/public/app/features/alerting/specs/threshold_mapper_specs.ts +++ b/public/app/features/alerting/specs/threshold_mapper_specs.ts @@ -4,7 +4,7 @@ import { ThresholdMapper } from '../threshold_mapper'; describe('ThresholdMapper', () => { describe('with greater than evaluator', () => { - it('can mapp query conditions to thresholds', () => { + it('can map query conditions to thresholds', () => { var panel: any = { type: 'graph', alert: { @@ -25,7 +25,7 @@ describe('ThresholdMapper', () => { }); describe('with outside range evaluator', () => { - it('can mapp query conditions to thresholds', () => { + it('can map query conditions to thresholds', () => { var panel: any = { type: 'graph', alert: { @@ -49,7 +49,7 @@ describe('ThresholdMapper', () => { }); describe('with inside range evaluator', () => { - it('can mapp query conditions to thresholds', () => { + it('can map query conditions to thresholds', () => { var panel: any = { type: 'graph', alert: { diff --git a/public/app/features/annotations/events_processing.ts b/public/app/features/annotations/events_processing.ts index 040bf6425c1..667285d7d43 100644 --- a/public/app/features/annotations/events_processing.ts +++ b/public/app/features/annotations/events_processing.ts @@ -56,7 +56,7 @@ function isStartOfRegion(event): boolean { export function dedupAnnotations(annotations) { let dedup = []; - // Split events by annotationId property existance + // Split events by annotationId property existence let events = _.partition(annotations, 'id'); let eventsById = _.groupBy(events[0], 'id'); diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index f2e2e3dcdc0..a8f491f3ddd 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -6,6 +6,7 @@ import './dashnav/dashnav'; import './submenu/submenu'; import './save_as_modal'; import './save_modal'; +import './save_provisioned_modal'; import './shareModalCtrl'; import './share_snapshot_ctrl'; import './dashboard_srv'; diff --git a/public/app/features/dashboard/change_tracker.ts b/public/app/features/dashboard/change_tracker.ts new file mode 100644 index 00000000000..745b76ce347 --- /dev/null +++ b/public/app/features/dashboard/change_tracker.ts @@ -0,0 +1,186 @@ +import angular from 'angular'; +import _ from 'lodash'; +import { DashboardModel } from './dashboard_model'; + +export class ChangeTracker { + current: any; + originalPath: any; + scope: any; + original: any; + next: any; + $window: any; + + /** @ngInject */ + constructor( + dashboard, + scope, + originalCopyDelay, + private $location, + $window, + private $timeout, + private contextSrv, + private $rootScope + ) { + this.$location = $location; + this.$window = $window; + + this.current = dashboard; + this.originalPath = $location.path(); + this.scope = scope; + + // register events + scope.onAppEvent('dashboard-saved', () => { + this.original = this.current.getSaveModelClone(); + this.originalPath = $location.path(); + }); + + $window.onbeforeunload = () => { + if (this.ignoreChanges()) { + return undefined; + } + if (this.hasChanges()) { + return 'There are unsaved changes to this dashboard'; + } + return undefined; + }; + + scope.$on('$locationChangeStart', (event, next) => { + // check if we should look for changes + if (this.originalPath === $location.path()) { + return true; + } + if (this.ignoreChanges()) { + return true; + } + + if (this.hasChanges()) { + event.preventDefault(); + this.next = next; + + this.$timeout(() => { + this.open_modal(); + }); + } + return false; + }); + + if (originalCopyDelay) { + this.$timeout(() => { + // wait for different services to patch the dashboard (missing properties) + this.original = dashboard.getSaveModelClone(); + }, originalCopyDelay); + } else { + this.original = dashboard.getSaveModelClone(); + } + } + + // for some dashboards and users + // changes should be ignored + ignoreChanges() { + if (!this.original) { + return true; + } + if (!this.contextSrv.isEditor) { + return true; + } + if (!this.current || !this.current.meta) { + return true; + } + + var meta = this.current.meta; + return !meta.canSave || meta.fromScript || meta.fromFile; + } + + // remove stuff that should not count in diff + cleanDashboardFromIgnoredChanges(dashData) { + // need to new up the domain model class to get access to expand / collapse row logic + let model = new DashboardModel(dashData); + + // Expand all rows before making comparison. This is required because row expand / collapse + // change order of panel array and panel positions. + model.expandRows(); + + let dash = model.getSaveModelClone(); + + // ignore time and refresh + dash.time = 0; + dash.refresh = 0; + dash.schemaVersion = 0; + + // ignore iteration property + delete dash.iteration; + + dash.panels = _.filter(dash.panels, panel => { + if (panel.repeatPanelId) { + return false; + } + + // remove scopedVars + panel.scopedVars = null; + + // ignore panel legend sort + if (panel.legend) { + delete panel.legend.sort; + delete panel.legend.sortDesc; + } + + return true; + }); + + // ignore template variable values + _.each(dash.templating.list, function(value) { + value.current = null; + value.options = null; + value.filters = null; + }); + + return dash; + } + + hasChanges() { + let current = this.cleanDashboardFromIgnoredChanges(this.current.getSaveModelClone()); + let original = this.cleanDashboardFromIgnoredChanges(this.original); + + var currentTimepicker = _.find(current.nav, { type: 'timepicker' }); + var originalTimepicker = _.find(original.nav, { type: 'timepicker' }); + + if (currentTimepicker && originalTimepicker) { + currentTimepicker.now = originalTimepicker.now; + } + + var currentJson = angular.toJson(current, true); + var originalJson = angular.toJson(original, true); + + return currentJson !== originalJson; + } + + discardChanges() { + this.original = null; + this.gotoNext(); + } + + open_modal() { + this.$rootScope.appEvent('show-modal', { + templateHtml: '', + modalClass: 'modal--narrow confirm-modal', + }); + } + + saveChanges() { + var self = this; + var cancel = this.$rootScope.$on('dashboard-saved', () => { + cancel(); + this.$timeout(() => { + self.gotoNext(); + }); + }); + + this.$rootScope.appEvent('save-dashboard'); + } + + gotoNext() { + var baseLen = this.$location.absUrl().length - this.$location.url().length; + var nextUrl = this.next.substring(baseLen); + this.$location.url(nextUrl); + } +} diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 3fa8ed9973a..8a300a80341 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -129,7 +129,7 @@ export class DashboardModel { this.meta = meta; } - // cleans meta data and other non peristent state + // cleans meta data and other non persistent state getSaveModelClone() { // make clone var copy: any = {}; @@ -606,7 +606,7 @@ export class DashboardModel { if (panel.gridPos.x + panel.gridPos.w * 2 <= GRID_COLUMN_COUNT) { newPanel.gridPos.x += panel.gridPos.w; } else { - // add bellow + // add below newPanel.gridPos.y += panel.gridPos.h; } @@ -649,6 +649,7 @@ export class DashboardModel { for (let panel of row.panels) { // make sure y is adjusted (in case row moved while collapsed) + // console.log('yDiff', yDiff); panel.gridPos.y -= yDiff; // insert after row this.panels.splice(insertPos, 0, new PanelModel(panel)); @@ -657,7 +658,7 @@ export class DashboardModel { yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h); } - const pushDownAmount = yMax - row.gridPos.y; + const pushDownAmount = yMax - row.gridPos.y - 1; // push panels below down for (let panelIndex = insertPos; panelIndex < this.panels.length; panelIndex++) { diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index 0ce448b6e88..b1419df7376 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -105,6 +105,10 @@ export class DashboardSrv { this.setCurrent(this.create(clone, this.dash.meta)); } + if (this.dash.meta.provisioned) { + return this.showDashboardProvisionedModal(); + } + if (!this.dash.meta.canSave && options.makeEditable !== true) { return Promise.resolve(); } @@ -124,6 +128,12 @@ export class DashboardSrv { return this.save(JSON.parse(json), {}); } + showDashboardProvisionedModal() { + this.$rootScope.appEvent('show-modal', { + templateHtml: '', + }); + } + showSaveAsModal() { this.$rootScope.appEvent('show-modal', { templateHtml: '', diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx index aeb840c317a..094bc49b708 100644 --- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx +++ b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx @@ -1,12 +1,13 @@ import React from 'react'; import _ from 'lodash'; - +import classNames from 'classnames'; import config from 'app/core/config'; import { PanelModel } from '../panel_model'; import { PanelContainer } from './PanelContainer'; import ScrollBar from 'app/core/components/ScrollBar/ScrollBar'; import store from 'app/core/store'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import Highlighter from 'react-highlight-words'; export interface AddPanelPanelProps { panel: PanelModel; @@ -16,21 +17,42 @@ export interface AddPanelPanelProps { export interface AddPanelPanelState { filter: string; panelPlugins: any[]; + copiedPanelPlugins: any[]; + tab: string; } export class AddPanelPanel extends React.Component { + private scrollbar: ScrollBar; + constructor(props) { super(props); this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this); this.renderPanelItem = this.renderPanelItem.bind(this); + this.panelSizeChanged = this.panelSizeChanged.bind(this); this.state = { - panelPlugins: this.getPanelPlugins(), + panelPlugins: this.getPanelPlugins(''), + copiedPanelPlugins: this.getCopiedPanelPlugins(''), filter: '', + tab: 'Add', }; } - getPanelPlugins() { + componentDidMount() { + this.props.panel.events.on('panel-size-changed', this.panelSizeChanged); + } + + componentWillUnmount() { + this.props.panel.events.off('panel-size-changed', this.panelSizeChanged); + } + + panelSizeChanged() { + setTimeout(() => { + this.scrollbar.update(); + }); + } + + getPanelPlugins(filter) { let panels = _.chain(config.panels) .filter({ hideFromList: false }) .map(item => item) @@ -39,6 +61,19 @@ export class AddPanelPanel extends React.Component item) + .value(); + let copiedPanels = []; + let copiedPanelJson = store.get(LS_PANEL_COPY_KEY); if (copiedPanelJson) { let copiedPanel = JSON.parse(copiedPanelJson); @@ -48,12 +83,13 @@ export class AddPanelPanel extends React.Component { @@ -92,28 +128,117 @@ export class AddPanelPanel extends React.Component; + } + renderPanelItem(panel, index) { return (
this.onAddPanel(panel)} title={panel.name}> -
{panel.name}
+
{this.renderText(panel.name)}
); } + noCopiedPanelPlugins() { + return
No copied panels yet.
; + } + + filterChange(evt) { + this.setState({ + filter: evt.target.value, + panelPlugins: this.getPanelPlugins(evt.target.value), + copiedPanelPlugins: this.getCopiedPanelPlugins(evt.target.value), + }); + } + + filterPanels(panels, filter) { + let regex = new RegExp(filter, 'i'); + return panels.filter(panel => { + return regex.test(panel.name); + }); + } + + openCopy() { + this.setState({ + tab: 'Copy', + filter: '', + panelPlugins: this.getPanelPlugins(''), + copiedPanelPlugins: this.getCopiedPanelPlugins(''), + }); + } + + openAdd() { + this.setState({ + tab: 'Add', + filter: '', + panelPlugins: this.getPanelPlugins(''), + copiedPanelPlugins: this.getCopiedPanelPlugins(''), + }); + } + render() { + let addClass = classNames({ + 'active active--panel': this.state.tab === 'Add', + '': this.state.tab === 'Copy', + }); + + let copyClass = classNames({ + '': this.state.tab === 'Add', + 'active active--panel': this.state.tab === 'Copy', + }); + + let panelTab; + + if (this.state.tab === 'Add') { + panelTab = this.state.panelPlugins.map(this.renderPanelItem); + } else if (this.state.tab === 'Copy') { + if (this.state.copiedPanelPlugins.length > 0) { + panelTab = this.state.copiedPanelPlugins.map(this.renderPanelItem); + } else { + panelTab = this.noCopiedPanelPlugins(); + } + } + return ( -
+
New Panel - Select a visualization +
    +
  • +
    + Add +
    +
  • +
  • +
    + Paste +
    +
  • +
- {this.state.panelPlugins.map(this.renderPanelItem)} + (this.scrollbar = element)} className="add-panel__items"> +
+ +
+ {panelTab} +
); diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index cbf23e3ea4b..b8ae18b14d3 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -19,9 +19,12 @@ export class FolderPickerCtrl { newFolderNameTouched: boolean; hasValidationError: boolean; validationError: any; + isEditor: boolean; /** @ngInject */ - constructor(private backendSrv, private validationSrv) { + constructor(private backendSrv, private validationSrv, private contextSrv) { + this.isEditor = this.contextSrv.isEditor; + if (!this.labelClass) { this.labelClass = 'width-7'; } @@ -38,19 +41,20 @@ export class FolderPickerCtrl { return this.backendSrv.get('api/search', params).then(result => { if ( - query === '' || - query.toLowerCase() === 'g' || - query.toLowerCase() === 'ge' || - query.toLowerCase() === 'gen' || - query.toLowerCase() === 'gene' || - query.toLowerCase() === 'gener' || - query.toLowerCase() === 'genera' || - query.toLowerCase() === 'general' + this.isEditor && + (query === '' || + query.toLowerCase() === 'g' || + query.toLowerCase() === 'ge' || + query.toLowerCase() === 'gen' || + query.toLowerCase() === 'gene' || + query.toLowerCase() === 'gener' || + query.toLowerCase() === 'genera' || + query.toLowerCase() === 'general') ) { result.unshift({ title: this.rootName, id: 0 }); } - if (this.enableCreateNew && query === '') { + if (this.isEditor && this.enableCreateNew && query === '') { result.unshift({ title: '-- New Folder --', id: -1 }); } diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/history/history.ts index d9f0c087438..be6ad5af1ba 100644 --- a/public/app/features/dashboard/history/history.ts +++ b/public/app/features/dashboard/history/history.ts @@ -133,7 +133,7 @@ export class HistoryListCtrl { return this.historySrv .getHistoryList(this.dashboard, options) .then(revisions => { - // set formated dates & default values + // set formatted dates & default values for (let rev of revisions) { rev.createdDateString = this.formatDate(rev.created); rev.ageString = this.formatBasicDate(rev.created); diff --git a/public/app/features/dashboard/save_provisioned_modal.ts b/public/app/features/dashboard/save_provisioned_modal.ts new file mode 100644 index 00000000000..ba96ce0b0b9 --- /dev/null +++ b/public/app/features/dashboard/save_provisioned_modal.ts @@ -0,0 +1,77 @@ +import angular from 'angular'; +import { saveAs } from 'file-saver'; +import coreModule from 'app/core/core_module'; + +const template = ` + +`; + +export class SaveProvisionedDashboardModalCtrl { + dash: any; + dashboardJson: string; + dismiss: () => void; + + /** @ngInject */ + constructor(dashboardSrv) { + this.dash = dashboardSrv.getCurrent().getSaveModelClone(); + delete this.dash.id; + this.dashboardJson = JSON.stringify(this.dash, null, 2); + } + + save() { + var blob = new Blob([angular.toJson(this.dash, true)], { + type: 'application/json;charset=utf-8', + }); + saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json'); + } + + getJsonForClipboard() { + return this.dashboardJson; + } +} + +export function saveProvisionedDashboardModalDirective() { + return { + restrict: 'E', + template: template, + controller: SaveProvisionedDashboardModalCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { dismiss: '&' }, + }; +} + +coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective); diff --git a/public/app/features/dashboard/specs/AddPanelPanel.jest.tsx b/public/app/features/dashboard/specs/AddPanelPanel.jest.tsx new file mode 100644 index 00000000000..872d9296d12 --- /dev/null +++ b/public/app/features/dashboard/specs/AddPanelPanel.jest.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { AddPanelPanel } from './../dashgrid/AddPanelPanel'; +import { PanelModel } from '../panel_model'; +import { shallow } from 'enzyme'; +import config from '../../../core/config'; + +jest.mock('app/core/store', () => ({ + get: key => { + return null; + }, + delete: key => { + return null; + }, +})); + +describe('AddPanelPanel', () => { + let wrapper, dashboardMock, getPanelContainer, panel; + + beforeEach(() => { + config.panels = [ + { + id: 'singlestat', + hideFromList: false, + name: 'Singlestat', + sort: 2, + info: { + logos: { + small: '', + }, + }, + }, + { + id: 'hidden', + hideFromList: true, + name: 'Hidden', + sort: 100, + info: { + logos: { + small: '', + }, + }, + }, + { + id: 'graph', + hideFromList: false, + name: 'Graph', + sort: 1, + info: { + logos: { + small: '', + }, + }, + }, + { + id: 'alexander_zabbix', + hideFromList: false, + name: 'Zabbix', + sort: 100, + info: { + logos: { + small: '', + }, + }, + }, + { + id: 'piechart', + hideFromList: false, + name: 'Piechart', + sort: 100, + info: { + logos: { + small: '', + }, + }, + }, + ]; + + dashboardMock = { toggleRow: jest.fn() }; + + getPanelContainer = jest.fn().mockReturnValue({ + getDashboard: jest.fn().mockReturnValue(dashboardMock), + getPanelLoader: jest.fn(), + }); + + panel = new PanelModel({ collapsed: false }); + wrapper = shallow(); + }); + + it('should fetch all panels sorted with core plugins first', () => { + //console.log(wrapper.debug()); + //console.log(wrapper.find('.add-panel__item').get(0).props.title); + expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('Singlestat'); + expect(wrapper.find('.add-panel__item').get(4).props.title).toBe('Piechart'); + }); + + it('should filter', () => { + wrapper.find('input').simulate('change', { target: { value: 'p' } }); + + expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('Piechart'); + expect(wrapper.find('.add-panel__item').get(0).props.title).toBe('Graph'); + }); +}); diff --git a/public/app/features/dashboard/specs/change_tracker.jest.ts b/public/app/features/dashboard/specs/change_tracker.jest.ts new file mode 100644 index 00000000000..5ec84aadbd0 --- /dev/null +++ b/public/app/features/dashboard/specs/change_tracker.jest.ts @@ -0,0 +1,99 @@ +import { ChangeTracker } from 'app/features/dashboard/change_tracker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardModel } from '../dashboard_model'; +import { PanelModel } from '../panel_model'; + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + user: { orgId: 1 }, + }, +})); + +describe('ChangeTracker', () => { + let rootScope; + let location; + let timeout; + let tracker: ChangeTracker; + let dash; + let scope; + + beforeEach(() => { + dash = new DashboardModel({ + refresh: false, + panels: [ + { + id: 1, + type: 'graph', + gridPos: { x: 0, y: 0, w: 24, h: 6 }, + legend: { sortDesc: false }, + }, + { + id: 2, + type: 'row', + gridPos: { x: 0, y: 6, w: 24, h: 2 }, + collapsed: true, + panels: [ + { id: 3, type: 'graph', gridPos: { x: 0, y: 6, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 6, w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } }, + ], + }); + + scope = { + appEvent: jest.fn(), + onAppEvent: jest.fn(), + $on: jest.fn(), + }; + + rootScope = { + appEvent: jest.fn(), + onAppEvent: jest.fn(), + $on: jest.fn(), + }; + + location = { + path: jest.fn(), + }; + + tracker = new ChangeTracker(dash, scope, undefined, location, window, timeout, contextSrv, rootScope); + }); + + it('No changes should not have changes', () => { + expect(tracker.hasChanges()).toBe(false); + }); + + it('Simple change should be registered', () => { + dash.title = 'google'; + expect(tracker.hasChanges()).toBe(true); + }); + + it('Should ignore a lot of changes', () => { + dash.time = { from: '1h' }; + dash.refresh = true; + dash.schemaVersion = 10; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore .iteration changes', () => { + dash.iteration = new Date().getTime() + 1; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore row collapse change', () => { + dash.toggleRow(dash.panels[1]); + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore panel legend changes', () => { + dash.panels[0].legend.sortDesc = true; + dash.panels[0].legend.sort = 'avg'; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore panel repeats', () => { + dash.panels.push(new PanelModel({ repeatPanelId: 10 })); + expect(tracker.hasChanges()).toBe(false); + }); +}); diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts index 1cb59ef5bac..737eb360461 100644 --- a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts @@ -56,7 +56,7 @@ describe('DashboardImportCtrl', function() { }); }); - describe('when specifing grafana.com url', function() { + describe('when specifying grafana.com url', function() { beforeEach(function() { ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123'; // setup api mock @@ -73,7 +73,7 @@ describe('DashboardImportCtrl', function() { }); }); - describe('when specifing dashbord id', function() { + describe('when specifying dashboard id', function() { beforeEach(function() { ctx.ctrl.gnetUrl = '2342'; // setup api mock diff --git a/public/app/features/dashboard/specs/dashboard_model.jest.ts b/public/app/features/dashboard/specs/dashboard_model.jest.ts index 99fe727c49d..feede679018 100644 --- a/public/app/features/dashboard/specs/dashboard_model.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_model.jest.ts @@ -374,14 +374,14 @@ describe('DashboardModel', function() { { id: 2, type: 'row', - gridPos: { x: 0, y: 6, w: 24, h: 2 }, + gridPos: { x: 0, y: 6, w: 24, h: 1 }, collapsed: true, panels: [ - { id: 3, type: 'graph', gridPos: { x: 0, y: 2, w: 12, h: 2 } }, - { id: 4, type: 'graph', gridPos: { x: 12, y: 2, w: 12, h: 2 } }, + { id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 7, w: 12, h: 2 } }, ], }, - { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } }, + { id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } }, ], }); dashboard.toggleRow(dashboard.panels[1]); @@ -399,16 +399,16 @@ describe('DashboardModel', function() { it('should position them below row', function() { expect(dashboard.panels[2].gridPos).toMatchObject({ x: 0, - y: 8, + y: 7, w: 12, h: 2, }); }); - it('should move panels below down', function() { + it.only('should move panels below down', function() { expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, - y: 10, + y: 9, w: 1, h: 1, }); diff --git a/public/app/features/dashboard/specs/save_provisioned_modal.jest.ts b/public/app/features/dashboard/specs/save_provisioned_modal.jest.ts new file mode 100644 index 00000000000..ce921cee8c8 --- /dev/null +++ b/public/app/features/dashboard/specs/save_provisioned_modal.jest.ts @@ -0,0 +1,30 @@ +import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal'; + +describe('SaveProvisionedDashboardModalCtrl', () => { + var json = { + title: 'name', + id: 5, + }; + + var mockDashboardSrv = { + getCurrent: function() { + return { + id: 5, + meta: {}, + getSaveModelClone: function() { + return json; + }, + }; + }, + }; + + var ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv); + + it('should remove id from dashboard model', () => { + expect(ctrl.dash.id).toBeUndefined(); + }); + + it('should remove id from dashboard model in clipboard json', () => { + expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2)); + }); +}); diff --git a/public/app/features/dashboard/specs/time_srv_specs.ts b/public/app/features/dashboard/specs/time_srv_specs.ts index ca75f0ffcf9..6e180679ff2 100644 --- a/public/app/features/dashboard/specs/time_srv_specs.ts +++ b/public/app/features/dashboard/specs/time_srv_specs.ts @@ -44,7 +44,7 @@ describe('timeSrv', function() { expect(time.raw.to).to.be('now'); }); - it('should handle formated dates', function() { + it('should handle formatted dates', function() { ctx.$location.search({ from: '20140410T052010', to: '20140520T031022' }); ctx.service.init(_dashboard); var time = ctx.service.timeRange(true); @@ -52,7 +52,7 @@ describe('timeSrv', function() { expect(time.to.valueOf()).to.equal(new Date('2014-05-20T03:10:22Z').getTime()); }); - it('should handle formated dates without time', function() { + it('should handle formatted dates without time', function() { ctx.$location.search({ from: '20140410', to: '20140520' }); ctx.service.init(_dashboard); var time = ctx.service.timeRange(true); diff --git a/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts b/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts deleted file mode 100644 index 8bd639de681..00000000000 --- a/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common'; -import { Tracker } from 'app/features/dashboard/unsaved_changes_srv'; -import 'app/features/dashboard/dashboard_srv'; -import { contextSrv } from 'app/core/core'; - -describe('unsavedChangesSrv', function() { - var _dashboardSrv; - var _contextSrvStub = { isEditor: true }; - var _rootScope; - var _location; - var _timeout; - var _window; - var tracker; - var dash; - var scope; - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach( - angularMocks.module(function($provide) { - $provide.value('contextSrv', _contextSrvStub); - $provide.value('$window', {}); - }) - ); - - beforeEach( - angularMocks.inject(function($location, $rootScope, dashboardSrv, $timeout, $window) { - _dashboardSrv = dashboardSrv; - _rootScope = $rootScope; - _location = $location; - _timeout = $timeout; - _window = $window; - }) - ); - - beforeEach(function() { - dash = _dashboardSrv.create({ - refresh: false, - panels: [{ test: 'asd', legend: {} }], - rows: [ - { - panels: [{ test: 'asd', legend: {} }], - }, - ], - }); - scope = _rootScope.$new(); - scope.appEvent = sinon.spy(); - scope.onAppEvent = sinon.spy(); - - tracker = new Tracker(dash, scope, undefined, _location, _window, _timeout, contextSrv, _rootScope); - }); - - it('No changes should not have changes', function() { - expect(tracker.hasChanges()).to.be(false); - }); - - it('Simple change should be registered', function() { - dash.property = 'google'; - expect(tracker.hasChanges()).to.be(true); - }); - - it('Should ignore a lot of changes', function() { - dash.time = { from: '1h' }; - dash.refresh = true; - dash.schemaVersion = 10; - expect(tracker.hasChanges()).to.be(false); - }); - - it('Should ignore .iteration changes', () => { - dash.iteration = new Date().getTime() + 1; - expect(tracker.hasChanges()).to.be(false); - }); - - it.skip('Should ignore row collapse change', function() { - dash.rows[0].collapse = true; - expect(tracker.hasChanges()).to.be(false); - }); - - it('Should ignore panel legend changes', function() { - dash.panels[0].legend.sortDesc = true; - dash.panels[0].legend.sort = 'avg'; - expect(tracker.hasChanges()).to.be(false); - }); - - it.skip('Should ignore panel repeats', function() { - dash.rows[0].panels.push({ repeatPanelId: 10 }); - expect(tracker.hasChanges()).to.be(false); - }); - - it.skip('Should ignore row repeats', function() { - dash.addEmptyRow(); - dash.rows[1].repeatRowId = 10; - expect(tracker.hasChanges()).to.be(false); - }); -}); diff --git a/public/app/features/dashboard/unsaved_changes_srv.ts b/public/app/features/dashboard/unsaved_changes_srv.ts index d4c12b8bcd6..0406e6a55d7 100644 --- a/public/app/features/dashboard/unsaved_changes_srv.ts +++ b/public/app/features/dashboard/unsaved_changes_srv.ts @@ -1,217 +1,10 @@ import angular from 'angular'; -import _ from 'lodash'; - -export class Tracker { - current: any; - originalPath: any; - scope: any; - original: any; - next: any; - $window: any; - - /** @ngInject */ - constructor( - dashboard, - scope, - originalCopyDelay, - private $location, - $window, - private $timeout, - private contextSrv, - private $rootScope - ) { - this.$location = $location; - this.$window = $window; - - this.current = dashboard; - this.originalPath = $location.path(); - this.scope = scope; - - // register events - scope.onAppEvent('dashboard-saved', () => { - this.original = this.current.getSaveModelClone(); - this.originalPath = $location.path(); - }); - - $window.onbeforeunload = () => { - if (this.ignoreChanges()) { - return undefined; - } - if (this.hasChanges()) { - return 'There are unsaved changes to this dashboard'; - } - return undefined; - }; - - scope.$on('$locationChangeStart', (event, next) => { - // check if we should look for changes - if (this.originalPath === $location.path()) { - return true; - } - if (this.ignoreChanges()) { - return true; - } - - if (this.hasChanges()) { - event.preventDefault(); - this.next = next; - - this.$timeout(() => { - this.open_modal(); - }); - } - return false; - }); - - if (originalCopyDelay) { - this.$timeout(() => { - // wait for different services to patch the dashboard (missing properties) - this.original = dashboard.getSaveModelClone(); - }, originalCopyDelay); - } else { - this.original = dashboard.getSaveModelClone(); - } - } - - // for some dashboards and users - // changes should be ignored - ignoreChanges() { - if (!this.original) { - return true; - } - if (!this.contextSrv.isEditor) { - return true; - } - if (!this.current || !this.current.meta) { - return true; - } - - var meta = this.current.meta; - return !meta.canSave || meta.fromScript || meta.fromFile; - } - - // remove stuff that should not count in diff - cleanDashboardFromIgnoredChanges(dash) { - // ignore time and refresh - dash.time = 0; - dash.refresh = 0; - dash.schemaVersion = 0; - - // ignore iteration property - delete dash.iteration; - - // filter row and panels properties that should be ignored - dash.rows = _.filter(dash.rows, function(row) { - if (row.repeatRowId) { - return false; - } - - row.panels = _.filter(row.panels, function(panel) { - if (panel.repeatPanelId) { - return false; - } - - // remove scopedVars - panel.scopedVars = null; - - // ignore span changes - panel.span = null; - - // ignore panel legend sort - if (panel.legend) { - delete panel.legend.sort; - delete panel.legend.sortDesc; - } - - return true; - }); - - // ignore collapse state - row.collapse = false; - return true; - }); - - dash.panels = _.filter(dash.panels, panel => { - if (panel.repeatPanelId) { - return false; - } - - // remove scopedVars - panel.scopedVars = null; - - // ignore panel legend sort - if (panel.legend) { - delete panel.legend.sort; - delete panel.legend.sortDesc; - } - - return true; - }); - - // ignore template variable values - _.each(dash.templating.list, function(value) { - value.current = null; - value.options = null; - value.filters = null; - }); - } - - hasChanges() { - var current = this.current.getSaveModelClone(); - var original = this.original; - - this.cleanDashboardFromIgnoredChanges(current); - this.cleanDashboardFromIgnoredChanges(original); - - var currentTimepicker = _.find(current.nav, { type: 'timepicker' }); - var originalTimepicker = _.find(original.nav, { type: 'timepicker' }); - - if (currentTimepicker && originalTimepicker) { - currentTimepicker.now = originalTimepicker.now; - } - - var currentJson = angular.toJson(current); - var originalJson = angular.toJson(original); - - return currentJson !== originalJson; - } - - discardChanges() { - this.original = null; - this.gotoNext(); - } - - open_modal() { - this.$rootScope.appEvent('show-modal', { - templateHtml: '', - modalClass: 'modal--narrow confirm-modal', - }); - } - - saveChanges() { - var self = this; - var cancel = this.$rootScope.$on('dashboard-saved', () => { - cancel(); - this.$timeout(() => { - self.gotoNext(); - }); - }); - - this.$rootScope.appEvent('save-dashboard'); - } - - gotoNext() { - var baseLen = this.$location.absUrl().length - this.$location.url().length; - var nextUrl = this.next.substring(baseLen); - this.$location.url(nextUrl); - } -} +import { ChangeTracker } from './change_tracker'; /** @ngInject */ export function unsavedChangesSrv($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) { - this.Tracker = Tracker; this.init = function(dashboard, scope) { - this.tracker = new Tracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope); + this.tracker = new ChangeTracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope); return this.tracker; }; } diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/view_state_srv.ts index 576b8b6fce8..1ed2d61df71 100644 --- a/public/app/features/dashboard/view_state_srv.ts +++ b/public/app/features/dashboard/view_state_srv.ts @@ -38,7 +38,7 @@ export class DashboardViewState { }); // this marks changes to location during this digest cycle as not to add history item - // dont want url changes like adding orgId to add browser history + // don't want url changes like adding orgId to add browser history $location.replace(); this.update(this.getQueryStringState()); } @@ -196,9 +196,10 @@ export class DashboardViewState { this.oldTimeRange = ctrl.range; this.fullscreenPanel = panelScope; + // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode() + this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode); this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id }); - this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); } registerPanel(panelScope) { diff --git a/public/app/features/org/partials/newOrg.html b/public/app/features/org/partials/newOrg.html index 424c55d6eb7..9777107c31a 100644 --- a/public/app/features/org/partials/newOrg.html +++ b/public/app/features/org/partials/newOrg.html @@ -5,7 +5,7 @@ New Organization -

Each organization contains their own dashboards, data sources and configuration, and cannot be shared between orgs. While users may belong to more than one, mutiple organization are most frequently used in multi-tenant deployments.

+

Each organization contains their own dashboards, data sources and configuration, and cannot be shared between orgs. While users may belong to more than one, multiple organization are most frequently used in multi-tenant deployments.

diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 177f0c7bf00..9e9598e1732 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -73,7 +73,7 @@ class MetricsPanelCtrl extends PanelCtrl { if (this.panel.snapshotData) { this.updateTimeRange(); var data = this.panel.snapshotData; - // backward compatability + // backward compatibility if (!_.isArray(data)) { data = data.data; } diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index f54877c2c37..da8adc4f908 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -194,8 +194,8 @@ export class PanelCtrl { }); menu.push({ - text: 'Add to Panel List', - click: 'ctrl.addToPanelList()', + text: 'Copy', + click: 'ctrl.copyPanel()', role: 'Editor', }); } @@ -260,9 +260,9 @@ export class PanelCtrl { }); } - addToPanelList() { + copyPanel() { store.set(LS_PANEL_COPY_KEY, JSON.stringify(this.panel.getSaveModel())); - appEvents.emit('alert-success', ['Panel temporarily added to panel list']); + appEvents.emit('alert-success', ['Panel copied. Open Add Panel to paste']); } replacePanel(newPanel, oldPanel) { diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index dec7868a553..e549ca262d3 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -1,6 +1,7 @@ import angular from 'angular'; +import $ from 'jquery'; import Drop from 'tether-drop'; -import PerfectScrollbar from 'perfect-scrollbar'; +import baron from 'baron'; var module = angular.module('grafana.directives'); @@ -86,6 +87,9 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) { function panelHeightUpdated() { panelContent.css({ height: ctrl.height + 'px' }); + } + + function resizeScrollableContent() { if (panelScrollbar) { panelScrollbar.update(); } @@ -100,9 +104,30 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) { // update scrollbar after mounting ctrl.events.on('component-did-mount', () => { if (ctrl.__proto__.constructor.scrollable) { - panelScrollbar = new PerfectScrollbar(panelContent[0], { - wheelPropagation: true, + const scrollRootClass = 'baron baron__root baron__clipper panel-content--scrollable'; + const scrollerClass = 'baron__scroller'; + const scrollBarHTML = ` +
+
+
+ `; + + let scrollRoot = panelContent; + let scroller = panelContent.find(':first').find(':first'); + + scrollRoot.addClass(scrollRootClass); + $(scrollBarHTML).appendTo(scrollRoot); + scroller.addClass(scrollerClass); + + panelScrollbar = baron({ + root: scrollRoot[0], + scroller: scroller[0], + bar: '.baron__bar', + barOnCls: '_scrollbar', + scrollingCls: '_scrolling', }); + + panelScrollbar.scroll(); } }); @@ -110,6 +135,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) { ctrl.calculatePanelHeight(); panelHeightUpdated(); $timeout(() => { + resizeScrollableContent(); ctrl.render(); }); }); @@ -199,7 +225,7 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) { } if (panelScrollbar) { - panelScrollbar.update(); + panelScrollbar.dispose(); } }); }, diff --git a/public/app/features/playlist/playlist_routes.ts b/public/app/features/playlist/playlist_routes.ts index c94446c2c1b..b898820e371 100644 --- a/public/app/features/playlist/playlist_routes.ts +++ b/public/app/features/playlist/playlist_routes.ts @@ -1,5 +1,6 @@ import angular from 'angular'; +/** @ngInject */ function grafanaRoutes($routeProvider) { $routeProvider .when('/playlists', { diff --git a/public/app/features/plugins/partials/ds_http_settings.html b/public/app/features/plugins/partials/ds_http_settings.html index 03df677ba13..b9f5683129c 100644 --- a/public/app/features/plugins/partials/ds_http_settings.html +++ b/public/app/features/plugins/partials/ds_http_settings.html @@ -1,5 +1,3 @@ - -

HTTP

@@ -13,12 +11,12 @@

Specify a complete HTTP URL (for example http://your_server:8080)

- Your access method is Direct, this means the URL + Your access method is Browser, this means the URL needs to be accessible from the browser. - Your access method is currently Proxy, this means the URL - needs to be accessible from the grafana backend. + Your access method is Server, this means the URL + needs to be accessible from the grafana backend/server.
@@ -27,14 +25,38 @@
Access -
- - - Direct = URL is used directly from browser
- Proxy = Grafana backend will proxy the request -
+
+
+
+ +
+
+ +
+
+

+ Access mode controls how requests to the data source will be handled. + Server should be the preferred way if nothing else stated. +

+
Server access mode (Default):
+

+ All requests will be made from the browser to Grafana backend/server which in turn will forward the requests to the data source + and by that circumvent possible Cross-Origin Resource Sharing (CORS) requirements. + The URL needs to be accessible from the grafana backend/server if you select this access mode. +

+
Browser access mode:
+

+ All requests will be made from the browser directly to the data source and may be subject to + Cross-Origin Resource Sharing (CORS) requirements. The URL needs to be accessible from the browser if you select this + access mode. +

+
@@ -135,4 +157,3 @@
- diff --git a/public/app/features/templating/datasource_variable.ts b/public/app/features/templating/datasource_variable.ts index 0c5b226c372..4c326a94e3b 100644 --- a/public/app/features/templating/datasource_variable.ts +++ b/public/app/features/templating/datasource_variable.ts @@ -29,7 +29,7 @@ export class DatasourceVariable implements Variable { getSaveModel() { assignModelProperties(this.model, this, this.defaults); - // dont persist options + // don't persist options this.model.options = []; return this.model; } diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index b87167ad646..54bd7bb660c 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -198,7 +198,9 @@ export class QueryVariable implements Variable { } }); } else if (sortType === 3) { - options = _.sortBy(options, opt => { return _.toLower(opt.text); }); + options = _.sortBy(options, opt => { + return _.toLower(opt.text); + }); } if (reverseSort) { diff --git a/public/app/features/templating/specs/adhoc_variable.jest.ts b/public/app/features/templating/specs/adhoc_variable.jest.ts index 863c8401c50..a7b20e8d029 100644 --- a/public/app/features/templating/specs/adhoc_variable.jest.ts +++ b/public/app/features/templating/specs/adhoc_variable.jest.ts @@ -2,7 +2,7 @@ import { AdhocVariable } from '../adhoc_variable'; describe('AdhocVariable', function() { describe('when serializing to url', function() { - it('should set return key value and op seperated by pipe', function() { + it('should set return key value and op separated by pipe', function() { var variable = new AdhocVariable({ filters: [ { key: 'key1', operator: '=', value: 'value1' }, diff --git a/public/app/features/templating/specs/query_variable.jest.ts b/public/app/features/templating/specs/query_variable.jest.ts index ce753a4b205..39c51874586 100644 --- a/public/app/features/templating/specs/query_variable.jest.ts +++ b/public/app/features/templating/specs/query_variable.jest.ts @@ -91,7 +91,6 @@ describe('QueryVariable', () => { it('should return in same order', () => { var i = 0; - console.log(result); expect(result.length).toBe(11); expect(result[i++].text).toBe(''); expect(result[i++].text).toBe('0'); diff --git a/public/app/features/templating/specs/template_srv.jest.ts b/public/app/features/templating/specs/template_srv.jest.ts index f28fbf9ac64..5290a883c48 100644 --- a/public/app/features/templating/specs/template_srv.jest.ts +++ b/public/app/features/templating/specs/template_srv.jest.ts @@ -282,7 +282,7 @@ describe('templateSrv', function() { }); }); - describe('can hightlight variables in string', function() { + describe('can highlight variables in string', function() { beforeEach(function() { initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 5b31072d140..f6274a80165 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -204,7 +204,7 @@ export class TemplateSrv { value = variable.current.value; if (this.isAllValue(value)) { value = this.getAllValue(variable); - // skip formating of custom all values + // skip formatting of custom all values if (variable.allValue) { return value; } diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html index 210275d2200..9506587c515 100644 --- a/public/app/partials/dashboard.html +++ b/public/app/partials/dashboard.html @@ -1,18 +1,18 @@
-
- - +
+ + -
- - +
+ + - - -
-
+ + +
+
diff --git a/public/app/plugins/app/testdata/dashboards/graph_last_1h.json b/public/app/plugins/app/testdata/dashboards/graph_last_1h.json index c56d9e9216f..5a4459cd62c 100644 --- a/public/app/plugins/app/testdata/dashboards/graph_last_1h.json +++ b/public/app/plugins/app/testdata/dashboards/graph_last_1h.json @@ -392,7 +392,7 @@ "thresholds": [], "timeFrom": null, "timeShift": null, - "title": "2 yaxis and axis lables", + "title": "2 yaxis and axis labels", "tooltip": { "msResolution": false, "shared": true, @@ -894,7 +894,7 @@ "thresholds": [], "timeFrom": null, "timeShift": null, - "title": "Legend Table Single Series Should Take Minium Height", + "title": "Legend Table Single Series Should Take Minimum Height", "tooltip": { "shared": true, "sort": 0, diff --git a/public/app/plugins/datasource/elasticsearch/elastic_response.ts b/public/app/plugins/datasource/elasticsearch/elastic_response.ts index ede5cb0ba3a..a378ab8b55f 100644 --- a/public/app/plugins/datasource/elasticsearch/elastic_response.ts +++ b/public/app/plugins/datasource/elasticsearch/elastic_response.ts @@ -175,7 +175,7 @@ export class ElasticResponse { } // This is quite complex - // neeed to recurise down the nested buckets to build series + // need to recurise down the nested buckets to build series processBuckets(aggs, target, seriesList, table, props, depth) { var bucket, aggDef, esAgg, aggId; var maxDepth = target.bucketAggs.length - 1; diff --git a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html index d4e1e7d1b1c..a2e903f231c 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html +++ b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html @@ -27,7 +27,7 @@
- Title (depricated) + Title (deprecated)
diff --git a/public/app/plugins/datasource/elasticsearch/partials/config.html b/public/app/plugins/datasource/elasticsearch/partials/config.html index da23e9ddab1..def59518624 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/config.html +++ b/public/app/plugins/datasource/elasticsearch/partials/config.html @@ -35,7 +35,7 @@
- Min interval + Min time interval A lower limit for the auto group by time interval. Recommended to be set to write frequency, diff --git a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts index 629621b8e60..558bccf3d0f 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts @@ -53,7 +53,7 @@ describe('ElasticDatasource', function() { }); }); - describe('When issueing metric query with interval pattern', function() { + describe('When issuing metric query with interval pattern', function() { var requestOptions, parts, header; beforeEach(function() { @@ -98,7 +98,7 @@ describe('ElasticDatasource', function() { }); }); - describe('When issueing document query', function() { + describe('When issuing document query', function() { var requestOptions, parts, header; beforeEach(function() { diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts index 6e64b5d12d0..444d30b5453 100644 --- a/public/app/plugins/datasource/graphite/add_graphite_func.ts +++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts @@ -68,7 +68,7 @@ export function graphiteAddFunc($compile) { }); $input.blur(function() { - // clicking the function dropdown menu wont + // clicking the function dropdown menu won't // work if you remove class at once setTimeout(function() { $input.val(''); diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 335cb400834..0b79673a14c 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -320,7 +320,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv method: 'GET', url: '/tags/autoComplete/tags', params: { - expr: _.map(expressions, expression => templateSrv.replace(expression)), + expr: _.map(expressions, expression => templateSrv.replace((expression || '').trim())), }, // for cancellations requestId: options.requestId, @@ -355,8 +355,8 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv method: 'GET', url: '/tags/autoComplete/values', params: { - expr: _.map(expressions, expression => templateSrv.replace(expression)), - tag: templateSrv.replace(tag), + expr: _.map(expressions, expression => templateSrv.replace((expression || '').trim())), + tag: templateSrv.replace((tag || '').trim()), }, // for cancellations requestId: options.requestId, @@ -453,7 +453,13 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv }; this.testDatasource = function() { - return this.metricFindQuery('*').then(function() { + let query = { + panelId: 3, + rangeRaw: { from: 'now-1h', to: 'now' }, + targets: [{ target: 'constantLine(100)' }], + maxDataPoints: 300, + }; + return this.query(query).then(function() { return { status: 'success', message: 'Data source is working' }; }); }; diff --git a/public/app/plugins/datasource/graphite/specs/datasource.jest.ts b/public/app/plugins/datasource/graphite/specs/datasource.jest.ts new file mode 100644 index 00000000000..dac6c2252d8 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/datasource.jest.ts @@ -0,0 +1,300 @@ +import { GraphiteDatasource } from '../datasource'; +import moment from 'moment'; +import _ from 'lodash'; +import $q from 'q'; +import { TemplateSrvStub } from 'test/specs/helpers'; + +describe('graphiteDatasource', () => { + let ctx: any = { + backendSrv: {}, + $q: $q, + templateSrv: new TemplateSrvStub(), + }; + + beforeEach(function() { + ctx.instanceSettings = { url: [''], name: 'graphiteProd', jsonData: {} }; + ctx.ds = new GraphiteDatasource(ctx.instanceSettings, ctx.$q, ctx.backendSrv, ctx.templateSrv); + }); + + describe('When querying graphite with one target using query editor target spec', function() { + let query = { + panelId: 3, + rangeRaw: { from: 'now-1h', to: 'now' }, + targets: [{ target: 'prod1.count' }, { target: 'prod2.count' }], + maxDataPoints: 500, + }; + + let results; + let requestOptions; + + beforeEach(async () => { + ctx.backendSrv.datasourceRequest = function(options) { + requestOptions = options; + return ctx.$q.when({ + data: [{ target: 'prod1.count', datapoints: [[10, 1], [12, 1]] }], + }); + }; + + await ctx.ds.query(query).then(function(data) { + results = data; + }); + }); + + it('should generate the correct query', function() { + expect(requestOptions.url).toBe('/render'); + }); + + it('should set unique requestId', function() { + expect(requestOptions.requestId).toBe('graphiteProd.panelId.3'); + }); + + it('should query correctly', function() { + let params = requestOptions.data.split('&'); + expect(params).toContain('target=prod1.count'); + expect(params).toContain('target=prod2.count'); + expect(params).toContain('from=-1h'); + expect(params).toContain('until=now'); + }); + + it('should exclude undefined params', function() { + let params = requestOptions.data.split('&'); + expect(params).not.toContain('cacheTimeout=undefined'); + }); + + it('should return series list', function() { + expect(results.data.length).toBe(1); + expect(results.data[0].target).toBe('prod1.count'); + }); + + it('should convert to millisecond resolution', function() { + expect(results.data[0].datapoints[0][0]).toBe(10); + }); + }); + + describe('when fetching Graphite Events as annotations', () => { + let results; + + const options = { + annotation: { + tags: 'tag1', + }, + range: { + from: moment(1432288354), + to: moment(1432288401), + }, + rangeRaw: { from: 'now-24h', to: 'now' }, + }; + + describe('and tags are returned as string', () => { + const response = { + data: [ + { + when: 1507222850, + tags: 'tag1 tag2', + data: 'some text', + id: 2, + what: 'Event - deploy', + }, + ], + }; + + beforeEach(async () => { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when(response); + }; + + await ctx.ds.annotationQuery(options).then(function(data) { + results = data; + }); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).toEqual(true); + expect(results[0].tags.length).toEqual(2); + expect(results[0].tags[0]).toEqual('tag1'); + expect(results[0].tags[1]).toEqual('tag2'); + }); + }); + + describe('and tags are returned as an array', () => { + const response = { + data: [ + { + when: 1507222850, + tags: ['tag1', 'tag2'], + data: 'some text', + id: 2, + what: 'Event - deploy', + }, + ], + }; + beforeEach(() => { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when(response); + }; + + ctx.ds.annotationQuery(options).then(function(data) { + results = data; + }); + // ctx.$rootScope.$apply(); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).toEqual(true); + expect(results[0].tags.length).toEqual(2); + expect(results[0].tags[0]).toEqual('tag1'); + expect(results[0].tags[1]).toEqual('tag2'); + }); + }); + }); + + describe('building graphite params', function() { + it('should return empty array if no targets', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{}], + }); + expect(results.length).toBe(0); + }); + + it('should uri escape targets', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'prod1.{test,test2}' }, { target: 'prod2.count' }], + }); + expect(results).toContain('target=prod1.%7Btest%2Ctest2%7D'); + }); + + it('should replace target placeholder', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: 'series2' }, { target: 'asPercent(#A,#B)' }], + }); + expect(results[2]).toBe('target=asPercent(series1%2Cseries2)'); + }); + + it('should replace target placeholder for hidden series', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [ + { target: 'series1', hide: true }, + { target: 'sumSeries(#A)', hide: true }, + { target: 'asPercent(#A,#B)' }, + ], + }); + expect(results[0]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); + }); + + it('should replace target placeholder when nesting query references', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: 'sumSeries(#A)' }, { target: 'asPercent(#A,#B)' }], + }); + expect(results[2]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); + }); + + it('should fix wrong minute interval parameters', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: "summarize(prod.25m.count, '25m', 'sum')" }], + }); + expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')")); + }); + + it('should fix wrong month interval parameters', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: "summarize(prod.5M.count, '5M', 'sum')" }], + }); + expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')")); + }); + + it('should ignore empty targets', function() { + let results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: '' }], + }); + expect(results.length).toBe(2); + }); + }); + + describe('querying for template variables', () => { + let results; + let requestOptions; + + beforeEach(() => { + ctx.backendSrv.datasourceRequest = function(options) { + requestOptions = options; + return ctx.$q.when({ + data: ['backend_01', 'backend_02'], + }); + }; + }); + + it('should generate tags query', () => { + ctx.ds.metricFindQuery('tags()').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tags query with a filter expression', () => { + ctx.ds.metricFindQuery('tags(server=backend_01)').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual(['server=backend_01']); + expect(results).not.toBe(null); + }); + + it('should generate tags query for an expression with whitespace after', () => { + ctx.ds.metricFindQuery('tags(server=backend_01 )').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual(['server=backend_01']); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for one tag', () => { + ctx.ds.metricFindQuery('tag_values(server)').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag and expression', () => { + ctx.ds.metricFindQuery('tag_values(server,server=~backend*)').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual(['server=~backend*']); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag with whitespace after', () => { + ctx.ds.metricFindQuery('tag_values(server )').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag and expression with whitespace after', () => { + ctx.ds.metricFindQuery('tag_values(server , server=~backend* )').then(data => { + results = data; + }); + + expect(requestOptions.url).toBe('/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual(['server=~backend*']); + expect(results).not.toBe(null); + }); + }); +}); diff --git a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts b/public/app/plugins/datasource/graphite/specs/datasource_specs.ts deleted file mode 100644 index b97b4a56b2b..00000000000 --- a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common'; -import helpers from 'test/specs/helpers'; -import { GraphiteDatasource } from '../datasource'; -import moment from 'moment'; -import _ from 'lodash'; - -describe('graphiteDatasource', function() { - let ctx = new helpers.ServiceTestContext(); - let instanceSettings: any = { url: [''], name: 'graphiteProd', jsonData: {} }; - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach(ctx.providePhase(['backendSrv', 'templateSrv'])); - beforeEach( - angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) { - ctx.$q = $q; - ctx.$httpBackend = $httpBackend; - ctx.$rootScope = $rootScope; - ctx.$injector = $injector; - $httpBackend.when('GET', /\.html$/).respond(''); - }) - ); - - beforeEach(function() { - ctx.ds = ctx.$injector.instantiate(GraphiteDatasource, { - instanceSettings: instanceSettings, - }); - }); - - describe('When querying graphite with one target using query editor target spec', function() { - let query = { - panelId: 3, - rangeRaw: { from: 'now-1h', to: 'now' }, - targets: [{ target: 'prod1.count' }, { target: 'prod2.count' }], - maxDataPoints: 500, - }; - - let results; - let requestOptions; - - beforeEach(function() { - ctx.backendSrv.datasourceRequest = function(options) { - requestOptions = options; - return ctx.$q.when({ - data: [{ target: 'prod1.count', datapoints: [[10, 1], [12, 1]] }], - }); - }; - - ctx.ds.query(query).then(function(data) { - results = data; - }); - ctx.$rootScope.$apply(); - }); - - it('should generate the correct query', function() { - expect(requestOptions.url).to.be('/render'); - }); - - it('should set unique requestId', function() { - expect(requestOptions.requestId).to.be('graphiteProd.panelId.3'); - }); - - it('should query correctly', function() { - let params = requestOptions.data.split('&'); - expect(params).to.contain('target=prod1.count'); - expect(params).to.contain('target=prod2.count'); - expect(params).to.contain('from=-1h'); - expect(params).to.contain('until=now'); - }); - - it('should exclude undefined params', function() { - let params = requestOptions.data.split('&'); - expect(params).to.not.contain('cacheTimeout=undefined'); - }); - - it('should return series list', function() { - expect(results.data.length).to.be(1); - expect(results.data[0].target).to.be('prod1.count'); - }); - - it('should convert to millisecond resolution', function() { - expect(results.data[0].datapoints[0][0]).to.be(10); - }); - }); - - describe('when fetching Graphite Events as annotations', () => { - let results; - - const options = { - annotation: { - tags: 'tag1', - }, - range: { - from: moment(1432288354), - to: moment(1432288401), - }, - rangeRaw: { from: 'now-24h', to: 'now' }, - }; - - describe('and tags are returned as string', () => { - const response = { - data: [ - { - when: 1507222850, - tags: 'tag1 tag2', - data: 'some text', - id: 2, - what: 'Event - deploy', - }, - ], - }; - - beforeEach(() => { - ctx.backendSrv.datasourceRequest = function(options) { - return ctx.$q.when(response); - }; - - ctx.ds.annotationQuery(options).then(function(data) { - results = data; - }); - ctx.$rootScope.$apply(); - }); - - it('should parse the tags string into an array', () => { - expect(_.isArray(results[0].tags)).to.eql(true); - expect(results[0].tags.length).to.eql(2); - expect(results[0].tags[0]).to.eql('tag1'); - expect(results[0].tags[1]).to.eql('tag2'); - }); - }); - - describe('and tags are returned as an array', () => { - const response = { - data: [ - { - when: 1507222850, - tags: ['tag1', 'tag2'], - data: 'some text', - id: 2, - what: 'Event - deploy', - }, - ], - }; - beforeEach(() => { - ctx.backendSrv.datasourceRequest = function(options) { - return ctx.$q.when(response); - }; - - ctx.ds.annotationQuery(options).then(function(data) { - results = data; - }); - ctx.$rootScope.$apply(); - }); - - it('should parse the tags string into an array', () => { - expect(_.isArray(results[0].tags)).to.eql(true); - expect(results[0].tags.length).to.eql(2); - expect(results[0].tags[0]).to.eql('tag1'); - expect(results[0].tags[1]).to.eql('tag2'); - }); - }); - }); - - describe('building graphite params', function() { - it('should return empty array if no targets', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{}], - }); - expect(results.length).to.be(0); - }); - - it('should uri escape targets', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: 'prod1.{test,test2}' }, { target: 'prod2.count' }], - }); - expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D'); - }); - - it('should replace target placeholder', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: 'series1' }, { target: 'series2' }, { target: 'asPercent(#A,#B)' }], - }); - expect(results[2]).to.be('target=asPercent(series1%2Cseries2)'); - }); - - it('should replace target placeholder for hidden series', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [ - { target: 'series1', hide: true }, - { target: 'sumSeries(#A)', hide: true }, - { target: 'asPercent(#A,#B)' }, - ], - }); - expect(results[0]).to.be('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); - }); - - it('should replace target placeholder when nesting query references', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: 'series1' }, { target: 'sumSeries(#A)' }, { target: 'asPercent(#A,#B)' }], - }); - expect(results[2]).to.be('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); - }); - - it('should fix wrong minute interval parameters', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: "summarize(prod.25m.count, '25m', 'sum')" }], - }); - expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')")); - }); - - it('should fix wrong month interval parameters', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: "summarize(prod.5M.count, '5M', 'sum')" }], - }); - expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')")); - }); - - it('should ignore empty targets', function() { - let results = ctx.ds.buildGraphiteParams({ - targets: [{ target: 'series1' }, { target: '' }], - }); - expect(results.length).to.be(2); - }); - }); -}); diff --git a/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts b/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts index f8b70b05940..b4f7718930f 100644 --- a/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts +++ b/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts @@ -97,7 +97,7 @@ describe('GraphiteQueryCtrl', function() { }); }); - describe('when initalizing target without metric expression and only function', function() { + describe('when initializing target without metric expression and only function', function() { beforeEach(function() { ctx.ctrl.target.target = 'asPercent(#A, #B)'; ctx.ctrl.datasource.metricFindQuery.returns(ctx.$q.when([])); @@ -130,7 +130,7 @@ describe('GraphiteQueryCtrl', function() { }); }); - describe('when initalizing target without metric expression and function with series-ref', function() { + describe('when initializing target without metric expression and function with series-ref', function() { beforeEach(function() { ctx.ctrl.target.target = 'asPercent(metric.node.count, #A)'; ctx.ctrl.datasource.metricFindQuery.returns(ctx.$q.when([])); @@ -146,7 +146,7 @@ describe('GraphiteQueryCtrl', function() { }); }); - describe('when getting altSegments and metricFindQuery retuns empty array', function() { + describe('when getting altSegments and metricFindQuery returns empty array', function() { beforeEach(function() { ctx.ctrl.target.target = 'test.count'; ctx.ctrl.datasource.metricFindQuery.returns(ctx.$q.when([])); diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 1eff9bfa527..f971ac2f649 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -54,7 +54,7 @@ export default class InfluxDatasource { queryTargets.push(target); - // backward compatability + // backward compatibility scopedVars.interval = scopedVars.__interval; queryModel = new InfluxQuery(target, this.templateSrv, scopedVars); @@ -82,7 +82,7 @@ export default class InfluxDatasource { // replace templated variables allQueries = this.templateSrv.replace(allQueries, scopedVars); - return this._seriesQuery(allQueries).then((data): any => { + return this._seriesQuery(allQueries, options).then((data): any => { if (!data || !data.results) { return []; } @@ -135,7 +135,7 @@ export default class InfluxDatasource { var query = options.annotation.query.replace('$timeFilter', timeFilter); query = this.templateSrv.replace(query, null, 'regex'); - return this._seriesQuery(query).then(data => { + return this._seriesQuery(query, options).then(data => { if (!data || !data.results || !data.results[0]) { throw { message: 'No results in response from InfluxDB' }; } @@ -164,30 +164,30 @@ export default class InfluxDatasource { return false; } - metricFindQuery(query) { + metricFindQuery(query: string, options?: any) { var interpolated = this.templateSrv.replace(query, null, 'regex'); - return this._seriesQuery(interpolated).then(_.curry(this.responseParser.parse)(query)); + return this._seriesQuery(interpolated, options).then(_.curry(this.responseParser.parse)(query)); } getTagKeys(options) { var queryBuilder = new InfluxQueryBuilder({ measurement: '', tags: [] }, this.database); var query = queryBuilder.buildExploreQuery('TAG_KEYS'); - return this.metricFindQuery(query); + return this.metricFindQuery(query, options); } getTagValues(options) { var queryBuilder = new InfluxQueryBuilder({ measurement: '', tags: [] }, this.database); var query = queryBuilder.buildExploreQuery('TAG_VALUES', options.key); - return this.metricFindQuery(query); + return this.metricFindQuery(query, options); } - _seriesQuery(query) { + _seriesQuery(query: string, options?: any) { if (!query) { return this.$q.when({ results: [] }); } - return this._influxRequest('GET', '/query', { q: query, epoch: 'ms' }); + return this._influxRequest('GET', '/query', { q: query, epoch: 'ms' }, options); } serializeParams(params) { @@ -225,21 +225,21 @@ export default class InfluxDatasource { }); } - _influxRequest(method, url, data) { - var self = this; + _influxRequest(method: string, url: string, data: any, options?: any) { + const currentUrl = this.urls.shift(); + this.urls.push(currentUrl); - var currentUrl = self.urls.shift(); - self.urls.push(currentUrl); + let params: any = {}; - var params: any = {}; - - if (self.username) { - params.u = self.username; - params.p = self.password; + if (this.username) { + params.u = this.username; + params.p = this.password; } - if (self.database) { - params.db = self.database; + if (options && options.database) { + params.db = options.database; + } else if (this.database) { + params.db = this.database; } if (method === 'GET') { @@ -247,7 +247,7 @@ export default class InfluxDatasource { data = null; } - var options: any = { + let req: any = { method: method, url: currentUrl + url, params: params, @@ -257,15 +257,15 @@ export default class InfluxDatasource { paramSerializer: this.serializeParams, }; - options.headers = options.headers || {}; + req.headers = req.headers || {}; if (this.basicAuth || this.withCredentials) { - options.withCredentials = true; + req.withCredentials = true; } - if (self.basicAuth) { - options.headers.Authorization = self.basicAuth; + if (this.basicAuth) { + req.headers.Authorization = this.basicAuth; } - return this.backendSrv.datasourceRequest(options).then( + return this.backendSrv.datasourceRequest(req).then( result => { return result.data; }, diff --git a/public/app/plugins/datasource/influxdb/influx_query.ts b/public/app/plugins/datasource/influxdb/influx_query.ts index 656647b4413..2ef74170068 100644 --- a/public/app/plugins/datasource/influxdb/influx_query.ts +++ b/public/app/plugins/datasource/influxdb/influx_query.ts @@ -230,7 +230,7 @@ export default class InfluxQuery { for (i = 0; i < this.groupByParts.length; i++) { var part = this.groupByParts[i]; if (i > 0) { - // for some reason fill has no seperator + // for some reason fill has no separator groupBySection += part.def.type === 'fill' ? ' ' : ', '; } groupBySection += part.render(''); diff --git a/public/app/plugins/datasource/influxdb/influx_series.ts b/public/app/plugins/datasource/influxdb/influx_series.ts index a177ef3bb73..89e2d01b85b 100644 --- a/public/app/plugins/datasource/influxdb/influx_series.ts +++ b/public/app/plugins/datasource/influxdb/influx_series.ts @@ -151,11 +151,17 @@ export default class InfluxSeries { _.each(this.series, (series, seriesIndex) => { if (seriesIndex === 0) { - table.columns.push({ text: 'Time', type: 'time' }); + j = 0; + // Check that the first column is indeed 'time' + if (series.columns[0] === 'time') { + // Push this now before the tags and with the right type + table.columns.push({ text: 'Time', type: 'time' }); + j++; + } _.each(_.keys(series.tags), function(key) { table.columns.push({ text: key }); }); - for (j = 1; j < series.columns.length; j++) { + for (; j < series.columns.length; j++) { table.columns.push({ text: series.columns[j] }); } } diff --git a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html index 2f54ff28275..48991426c1e 100644 --- a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html +++ b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html @@ -17,7 +17,7 @@
- Title (depricated) + Title (deprecated)
diff --git a/public/app/plugins/datasource/influxdb/partials/config.html b/public/app/plugins/datasource/influxdb/partials/config.html index 9cb6f5ba749..a70a1de98a4 100644 --- a/public/app/plugins/datasource/influxdb/partials/config.html +++ b/public/app/plugins/datasource/influxdb/partials/config.html @@ -23,6 +23,20 @@ + +
+
+
Database Access
+

+ Setting the database for this datasource does not deny access to other databases. The InfluxDB query syntax allows + switching the database in the query. For example: + SHOW MEASUREMENTS ON _internal or SELECT * FROM "_internal".."database" LIMIT 10 +

+ To support data isolation and security, make sure appropriate permissions are configured in InfluxDB. +

+
+
+
diff --git a/public/app/plugins/datasource/influxdb/query_help.md b/public/app/plugins/datasource/influxdb/query_help.md index 0d4fd941ca5..4930ccbc83f 100644 --- a/public/app/plugins/datasource/influxdb/query_help.md +++ b/public/app/plugins/datasource/influxdb/query_help.md @@ -10,7 +10,7 @@ - When stacking is enabled it is important that points align - If there are missing points for one series it can cause gaps or missing bars - You must use fill(0), and select a group by time low limit -- Use the group by time option below your queries and specify for example >10s if your metrics are written every 10 seconds +- Use the group by time option below your queries and specify for example 10s if your metrics are written every 10 seconds - This will insert zeros for series that are missing measurements and will make stacking work properly #### Group by time @@ -18,8 +18,7 @@ - Leave the group by time field empty for each query and it will be calculated based on time range and pixel width of the graph - If you use fill(0) or fill(null) set a low limit for the auto group by time interval - The low limit can only be set in the group by time option below your queries -- You set a low limit by adding a greater sign before the interval -- Example: >60s if you write metrics to InfluxDB every 60 seconds +- Example: 60s if you write metrics to InfluxDB every 60 seconds #### Documentation links: diff --git a/public/app/plugins/datasource/influxdb/specs/influx_series.jest.ts b/public/app/plugins/datasource/influxdb/specs/influx_series.jest.ts index 8ae4a335828..8c8fee9ab9f 100644 --- a/public/app/plugins/datasource/influxdb/specs/influx_series.jest.ts +++ b/public/app/plugins/datasource/influxdb/specs/influx_series.jest.ts @@ -195,10 +195,34 @@ describe('when generating timeseries from influxdb response', function() { expect(table.type).toBe('table'); expect(table.columns.length).toBe(5); + expect(table.columns[0].text).toEqual('Time'); expect(table.rows[0]).toEqual([1431946625000, 'Africa', 'server2', 23, 10]); }); }); + describe('given table response from SHOW CARDINALITY', function() { + var options = { + alias: '', + series: [ + { + name: 'cpu', + columns: ['count'], + values: [[37]], + }, + ], + }; + + it('should return table', function() { + var series = new InfluxSeries(options); + var table = series.getTable(); + + expect(table.type).toBe('table'); + expect(table.columns.length).toBe(1); + expect(table.columns[0].text).toEqual('count'); + expect(table.rows[0]).toEqual([37]); + }); + }); + describe('given annotation response', function() { describe('with empty tagsColumn', function() { var options = { diff --git a/public/app/plugins/datasource/influxdb/specs/query_builder.jest.ts b/public/app/plugins/datasource/influxdb/specs/query_builder.jest.ts index 439bf7b1fc5..eeae987b139 100644 --- a/public/app/plugins/datasource/influxdb/specs/query_builder.jest.ts +++ b/public/app/plugins/datasource/influxdb/specs/query_builder.jest.ts @@ -97,7 +97,7 @@ describe('InfluxQueryBuilder', function() { expect(query).toBe('SHOW TAG VALUES FROM "one_week"."cpu" WITH KEY = "app" WHERE "host" = \'server1\''); }); - it('should not includ policy when policy is default', function() { + it('should not include policy when policy is default', function() { var builder = new InfluxQueryBuilder({ measurement: 'cpu', policy: 'default', diff --git a/public/app/plugins/datasource/mssql/partials/annotations.editor.html b/public/app/plugins/datasource/mssql/partials/annotations.editor.html index 8a94c470379..b2c0d7b97a6 100644 --- a/public/app/plugins/datasource/mssql/partials/annotations.editor.html +++ b/public/app/plugins/datasource/mssql/partials/annotations.editor.html @@ -18,7 +18,7 @@
Annotation Query Format
-An annotation is an event that is overlayed on top of graphs. The query can have up to three columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. +An annotation is an event that is overlaid on top of graphs. The query can have up to three columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. - column with alias: time for the annotation event time. Use epoch time or any native date data type. - column with alias: text for the annotation text. diff --git a/public/app/plugins/datasource/mysql/partials/annotations.editor.html b/public/app/plugins/datasource/mysql/partials/annotations.editor.html index d142e091fed..23ec726a9f0 100644 --- a/public/app/plugins/datasource/mysql/partials/annotations.editor.html +++ b/public/app/plugins/datasource/mysql/partials/annotations.editor.html @@ -18,7 +18,7 @@
Annotation Query Format
-An annotation is an event that is overlayed on top of graphs. The query can have up to three columns per row, the time or time_sec column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. +An annotation is an event that is overlaid on top of graphs. The query can have up to three columns per row, the time or time_sec column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. - column with alias: time or time_sec for the annotation event time. Use epoch time or any native date data type. - column with alias: text for the annotation text diff --git a/public/app/plugins/datasource/postgres/img/postgresql_logo.svg b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg index d98e3659c39..40a39970070 100644 --- a/public/app/plugins/datasource/postgres/img/postgresql_logo.svg +++ b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg @@ -3,7 +3,7 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - + @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/public/app/plugins/datasource/postgres/module.ts b/public/app/plugins/datasource/postgres/module.ts index acd23318b6d..a24971fa1a1 100644 --- a/public/app/plugins/datasource/postgres/module.ts +++ b/public/app/plugins/datasource/postgres/module.ts @@ -8,7 +8,7 @@ class PostgresConfigCtrl { /** @ngInject **/ constructor($scope) { - this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'require'; + this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full'; } } diff --git a/public/app/plugins/datasource/postgres/partials/annotations.editor.html b/public/app/plugins/datasource/postgres/partials/annotations.editor.html index 09232d6f8ed..b83f5a14832 100644 --- a/public/app/plugins/datasource/postgres/partials/annotations.editor.html +++ b/public/app/plugins/datasource/postgres/partials/annotations.editor.html @@ -18,7 +18,7 @@
Annotation Query Format
-An annotation is an event that is overlayed on top of graphs. The query can have up to three columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. +An annotation is an event that is overlaid on top of graphs. The query can have up to three columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. - column with alias: time for the annotation event time. Use epoch time or any native date data type. - column with alias: text for the annotation text @@ -28,12 +28,12 @@ An annotation is an event that is overlayed on top of graphs. The query can have Macros: - $__time(column) -> column as "time" - $__timeEpoch -> extract(epoch from column) as "time" -- $__timeFilter(column) -> column ≥ to_timestamp(1492750877) AND column ≤ to_timestamp(1492750877) -- $__unixEpochFilter(column) -> column > 1492750877 AND column < 1492750877 +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 Or build your own conditionals using these macros which just return the values: -- $__timeFrom() -> to_timestamp(1492750877) -- $__timeTo() -> to_timestamp(1492750877) +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' - $__unixEpochFrom() -> 1492750877 - $__unixEpochTo() -> 1492750877
diff --git a/public/app/plugins/datasource/postgres/partials/query.editor.html b/public/app/plugins/datasource/postgres/partials/query.editor.html index 163970a9ad5..26392c17356 100644 --- a/public/app/plugins/datasource/postgres/partials/query.editor.html +++ b/public/app/plugins/datasource/postgres/partials/query.editor.html @@ -48,8 +48,8 @@ Table: Macros: - $__time(column) -> column as "time" - $__timeEpoch -> extract(epoch from column) as "time" -- $__timeFilter(column) -> extract(epoch from column) BETWEEN 1492750877 AND 1492750877 -- $__unixEpochFilter(column) -> column > 1492750877 AND column < 1492750877 +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 - $__timeGroup(column,'5m') -> (extract(epoch from column)/300)::bigint*300 AS time Example of group by and order by with $__timeGroup: @@ -61,8 +61,8 @@ GROUP BY time ORDER BY time Or build your own conditionals using these macros which just return the values: -- $__timeFrom() -> to_timestamp(1492750877) -- $__timeTo() -> to_timestamp(1492750877) +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' - $__unixEpochFrom() -> 1492750877 - $__unixEpochTo() -> 1492750877
diff --git a/public/app/plugins/datasource/prometheus/partials/query.editor.html b/public/app/plugins/datasource/prometheus/partials/query.editor.html index 8d6e89c3406..68791d96c19 100644 --- a/public/app/plugins/datasource/prometheus/partials/query.editor.html +++ b/public/app/plugins/datasource/prometheus/partials/query.editor.html @@ -14,7 +14,7 @@ data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()"> - Controls the name of the time series, using name or pattern. For example {{hostname}} will be replaced with label value for + Controls the name of the time series, using name or pattern. For example {{hostname}} will be replaced with label value for the label hostname.
diff --git a/public/app/plugins/panel/dashlist/module.html b/public/app/plugins/panel/dashlist/module.html index 8fa3e7ef71f..fdba0c79f35 100644 --- a/public/app/plugins/panel/dashlist/module.html +++ b/public/app/plugins/panel/dashlist/module.html @@ -1,17 +1,19 @@ -
-
-
- {{group.header}} -
-
- - - {{dash.title}} - - - - - +
+
+
+
+ {{group.header}} +
+
diff --git a/public/app/plugins/panel/graph/jquery.flot.events.js b/public/app/plugins/panel/graph/jquery.flot.events.js index 1aa79c5056f..3ea3ca8f330 100644 --- a/public/app/plugins/panel/graph/jquery.flot.events.js +++ b/public/app/plugins/panel/graph/jquery.flot.events.js @@ -52,14 +52,14 @@ function ($, _, angular, Drop) { var eventManager = plot.getOptions().events.manager; if (eventManager.editorOpen) { // update marker element to attach to (needed in case of legend on the right - // when there is a double render pass and the inital marker element is removed) + // when there is a double render pass and the initial marker element is removed) markerElementToAttachTo = element; return; } // mark as openend eventManager.editorOpened(); - // set marker elment to attache to + // set marker element to attache to markerElementToAttachTo = element; // wait for element to be attached and positioned diff --git a/public/app/plugins/panel/graph/legend.ts b/public/app/plugins/panel/graph/legend.ts index 4dfeb75ff55..6b6c89444dc 100644 --- a/public/app/plugins/panel/graph/legend.ts +++ b/public/app/plugins/panel/graph/legend.ts @@ -1,7 +1,7 @@ import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; -import PerfectScrollbar from 'perfect-scrollbar'; +import baron from 'baron'; var module = angular.module('grafana.directives'); @@ -16,11 +16,10 @@ module.directive('graphLegend', function(popoverSrv, $timeout) { var i; var legendScrollbar; const legendRightDefaultWidth = 10; + let legendElem = elem.parent(); scope.$on('$destroy', function() { - if (legendScrollbar) { - legendScrollbar.destroy(); - } + destroyScrollbar(); }); ctrl.events.on('render-legend', () => { @@ -112,7 +111,7 @@ module.directive('graphLegend', function(popoverSrv, $timeout) { } function render() { - let legendWidth = elem.width(); + let legendWidth = legendElem.width(); if (!ctrl.panel.legend.show) { elem.empty(); firstRender = true; @@ -130,12 +129,12 @@ module.directive('graphLegend', function(popoverSrv, $timeout) { elem.empty(); - // Set min-width if side style and there is a value, otherwise remove the CSS propery + // Set min-width if side style and there is a value, otherwise remove the CSS property // Set width so it works with IE11 var width: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : ''; var ieWidth: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth - 1 + 'px' : ''; - elem.css('min-width', width); - elem.css('width', ieWidth); + legendElem.css('min-width', width); + legendElem.css('width', ieWidth); elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true); @@ -241,8 +240,10 @@ module.directive('graphLegend', function(popoverSrv, $timeout) { tbodyElem.append(tableHeaderElem); tbodyElem.append(seriesElements); elem.append(tbodyElem); + tbodyElem.wrap('
'); } else { - elem.append(seriesElements); + elem.append('
'); + elem.find('.graph-legend-scroll').append(seriesElements); } if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) { @@ -253,23 +254,45 @@ module.directive('graphLegend', function(popoverSrv, $timeout) { } function addScrollbar() { - const scrollbarOptions = { - // Number of pixels the content height can surpass the container height without enabling the scroll bar. - scrollYMarginOffset: 2, - suppressScrollX: true, - wheelPropagation: true, + const scrollRootClass = 'baron baron__root'; + const scrollerClass = 'baron__scroller'; + const scrollBarHTML = ` +
+
+
+ `; + + let scrollRoot = elem; + let scroller = elem.find('.graph-legend-scroll'); + + // clear existing scroll bar track to prevent duplication + scrollRoot.find('.baron__track').remove(); + + scrollRoot.addClass(scrollRootClass); + $(scrollBarHTML).appendTo(scrollRoot); + scroller.addClass(scrollerClass); + + let scrollbarParams = { + root: scrollRoot[0], + scroller: scroller[0], + bar: '.baron__bar', + track: '.baron__track', + barOnCls: '_scrollbar', + scrollingCls: '_scrolling', }; if (!legendScrollbar) { - legendScrollbar = new PerfectScrollbar(elem[0], scrollbarOptions); + legendScrollbar = baron(scrollbarParams); } else { - legendScrollbar.update(); + destroyScrollbar(); + legendScrollbar = baron(scrollbarParams); } + legendScrollbar.scroll(); } function destroyScrollbar() { if (legendScrollbar) { - legendScrollbar.destroy(); + legendScrollbar.dispose(); legendScrollbar = undefined; } } diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.ts b/public/app/plugins/panel/graph/series_overrides_ctrl.ts index 703c4648716..ecf79a8a4fb 100644 --- a/public/app/plugins/panel/graph/series_overrides_ctrl.ts +++ b/public/app/plugins/panel/graph/series_overrides_ctrl.ts @@ -31,7 +31,7 @@ export class SeriesOverridesCtrl { $scope.override[item.propertyName] = subItem.value; - // automatically disable lines for this series and the fill bellow to series + // automatically disable lines for this series and the fill below to series // can be removed by the user if they still want lines if (item.propertyName === 'fillBelowTo') { $scope.override['lines'] = false; diff --git a/public/app/plugins/panel/graph/template.ts b/public/app/plugins/panel/graph/template.ts index 0b9eb8227df..c897327fe1a 100644 --- a/public/app/plugins/panel/graph/template.ts +++ b/public/app/plugins/panel/graph/template.ts @@ -3,7 +3,9 @@ var template = `
-
+
+
+
`; diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 776033536dd..c7f523a9591 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -159,8 +159,8 @@ class SingleStatCtrl extends MetricsPanelCtrl { } setTableColumnToSensibleDefault(tableData) { - if (this.tableColumnOptions.length === 1) { - this.panel.tableColumn = this.tableColumnOptions[0]; + if (tableData.columns.length === 1) { + this.panel.tableColumn = tableData.columns[0].text; } else { this.panel.tableColumn = _.find(tableData.columns, col => { return col.type !== 'time'; diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index a59b3ae48ee..eefe3f9bdc0 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -221,7 +221,7 @@ describe('when transforming time series table', () => { expect(table.rows[0][2]).toBe(42); }); - it('should return 2 rows for a mulitple queries with same label values plus one extra row', () => { + it('should return 2 rows for a multiple queries with same label values plus one extra row', () => { table = transformDataToTable(multipleQueriesDataSameLabels, panel); expect(table.rows.length).toBe(2); expect(table.rows[0][0]).toBe(time); @@ -238,7 +238,7 @@ describe('when transforming time series table', () => { expect(table.rows[1][5]).toBe(7); }); - it('should return 2 rows for mulitple queries with different label values', () => { + it('should return 2 rows for multiple queries with different label values', () => { table = transformDataToTable(multipleQueriesDataDifferentLabels, panel); expect(table.rows.length).toBe(2); expect(table.columns.length).toBe(6); diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 43088dc22ac..1659ba3e3aa 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -243,7 +243,7 @@ transformers['table'] = { row[columnIndex] = matchedRow[columnIndex]; } } - // Dont visit this row again + // Don't visit this row again mergedRows[match] = matchedRow; // Keep looking for more rows to merge offset = match + 1; diff --git a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts index c3bc6016e50..d6a20e25846 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts @@ -15,7 +15,23 @@ describe('PermissionsStore', () => { permission: 1, permissionName: 'View', teamId: 1, - teamName: 'MyTestTeam', + team: 'MyTestTeam', + }, + { + id: 5, + dashboardId: 1, + permission: 1, + permissionName: 'View', + userId: 1, + userLogin: 'MyTestUser', + }, + { + id: 6, + dashboardId: 1, + permission: 1, + permissionName: 'Edit', + teamId: 2, + team: 'MyTestTeam2', }, ]) ); @@ -48,15 +64,24 @@ describe('PermissionsStore', () => { }); it('should save removed permissions automatically', async () => { - expect(store.items.length).toBe(3); + expect(store.items.length).toBe(5); await store.removeStoreItem(2); - expect(store.items.length).toBe(2); + expect(store.items.length).toBe(4); expect(backendSrv.post.mock.calls.length).toBe(1); expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); + it('should be sorted by sort rank and alphabetically', async () => { + expect(store.items[0].name).toBe('MyTestTeam'); + expect(store.items[0].dashboardId).toBe(10); + expect(store.items[1].name).toBe('Editor'); + expect(store.items[2].name).toBe('Viewer'); + expect(store.items[3].name).toBe('MyTestTeam2'); + expect(store.items[4].name).toBe('MyTestUser'); + }); + describe('when one inherited and one not inherited team permission are added', () => { beforeEach(async () => { const overridingItemForChildDashboard = { @@ -73,7 +98,18 @@ describe('PermissionsStore', () => { }); it('should add new overriding permission', () => { - expect(store.items.length).toBe(4); + expect(store.items.length).toBe(6); + }); + + it('should be sorted by sort rank and alphabetically', async () => { + expect(store.items[0].name).toBe('MyTestTeam'); + expect(store.items[0].dashboardId).toBe(10); + expect(store.items[1].name).toBe('Editor'); + expect(store.items[2].name).toBe('Viewer'); + expect(store.items[3].name).toBe('MyTestTeam'); + expect(store.items[3].dashboardId).toBe(1); + expect(store.items[4].name).toBe('MyTestTeam2'); + expect(store.items[5].name).toBe('MyTestUser'); }); }); }); diff --git a/public/app/stores/PermissionsStore/PermissionsStore.ts b/public/app/stores/PermissionsStore/PermissionsStore.ts index 79df593f06e..833d1bdaac7 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.ts @@ -30,6 +30,8 @@ export const NewPermissionsItem = types ), userId: types.maybe(types.number), userLogin: types.maybe(types.string), + userAvatarUrl: types.maybe(types.string), + teamAvatarUrl: types.maybe(types.string), teamId: types.maybe(types.number), team: types.maybe(types.string), permission: types.optional(types.number, 1), @@ -50,17 +52,19 @@ export const NewPermissionsItem = types }, })) .actions(self => ({ - setUser(userId: number, userLogin: string) { + setUser(userId: number, userLogin: string, userAvatarUrl: string) { self.userId = userId; self.userLogin = userLogin; + self.userAvatarUrl = userAvatarUrl; self.teamId = null; self.team = null; }, - setTeam(teamId: number, team: string) { + setTeam(teamId: number, team: string, teamAvatarUrl: string) { self.userId = null; self.userLogin = null; self.teamId = teamId; self.team = team; + self.teamAvatarUrl = teamAvatarUrl; }, setPermission(permission: number) { self.permission = permission; @@ -121,16 +125,20 @@ export const PermissionsStore = types teamId: undefined, userLogin: undefined, userId: undefined, + userAvatarUrl: undefined, + teamAvatarUrl: undefined, role: undefined, }; switch (self.newItem.type) { case aclTypeValues.GROUP.value: item.team = self.newItem.team; item.teamId = self.newItem.teamId; + item.teamAvatarUrl = self.newItem.teamAvatarUrl; break; case aclTypeValues.USER.value: item.userLogin = self.newItem.userLogin; item.userId = self.newItem.userId; + item.userAvatarUrl = self.newItem.userAvatarUrl; break; case aclTypeValues.VIEWER.value: case aclTypeValues.EDITOR.value: @@ -147,6 +155,8 @@ export const PermissionsStore = types try { yield updateItems(self, updatedItems); self.items.push(newItem); + let sortedItems = self.items.sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name)); + self.items = sortedItems; resetNewTypeInternal(); } catch {} yield Promise.resolve(); @@ -206,9 +216,11 @@ const updateItems = (self, items) => { }; const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => { - return response.map(item => { - return prepareItem(item, dashboardId, isFolder, isInRoot); - }); + return response + .map(item => { + return prepareItem(item, dashboardId, isFolder, isInRoot); + }) + .sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name)); }; const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boolean) => { @@ -216,21 +228,16 @@ const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boo item.sortRank = 0; if (item.userId > 0) { - item.icon = 'fa fa-fw fa-user'; - item.nameHtml = item.userLogin; - item.sortName = item.userLogin; + item.name = item.userLogin; item.sortRank = 10; } else if (item.teamId > 0) { - item.icon = 'fa fa-fw fa-users'; - item.nameHtml = item.team; - item.sortName = item.team; + item.name = item.team; item.sortRank = 20; } else if (item.role) { item.icon = 'fa fa-fw fa-street-view'; - item.nameHtml = `Everyone with ${item.role} Role`; - item.sortName = item.role; + item.name = item.role; item.sortRank = 30; - if (item.role === 'Viewer') { + if (item.role === 'Editor') { item.sortRank += 1; } } diff --git a/public/app/stores/PermissionsStore/PermissionsStoreItem.ts b/public/app/stores/PermissionsStore/PermissionsStoreItem.ts index 92dca0220ca..c4873cb9c01 100644 --- a/public/app/stores/PermissionsStore/PermissionsStoreItem.ts +++ b/public/app/stores/PermissionsStore/PermissionsStoreItem.ts @@ -14,8 +14,9 @@ export const PermissionsStoreItem = types inherited: types.maybe(types.boolean), sortRank: types.maybe(types.number), icon: types.maybe(types.string), - nameHtml: types.maybe(types.string), - sortName: types.maybe(types.string), + name: types.maybe(types.string), + teamAvatarUrl: types.maybe(types.string), + userAvatarUrl: types.maybe(types.string), }) .actions(self => ({ updateRole: role => { diff --git a/public/dashboards/scripted_templated.js b/public/dashboards/scripted_templated.js index 5a05aa55b5d..f1b0b115fa1 100644 --- a/public/dashboards/scripted_templated.js +++ b/public/dashboards/scripted_templated.js @@ -22,7 +22,7 @@ var dashboard; // All url parameters are available via the ARGS object var ARGS; -// Intialize a skeleton with nothing but a rows array and service object +// Initialize a skeleton with nothing but a rows array and service object dashboard = { rows : [], schemaVersion: 13, diff --git a/public/img/icons_dark_theme/icon_editor.svg b/public/img/icons_dark_theme/icon_editor.svg new file mode 100644 index 00000000000..00c60902fbc --- /dev/null +++ b/public/img/icons_dark_theme/icon_editor.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/public/img/icons_dark_theme/icon_viewer.svg b/public/img/icons_dark_theme/icon_viewer.svg new file mode 100644 index 00000000000..aec3e6b7e5b --- /dev/null +++ b/public/img/icons_dark_theme/icon_viewer.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/public/img/icons_light_theme/icon_editor.svg b/public/img/icons_light_theme/icon_editor.svg new file mode 100644 index 00000000000..a6581072a17 --- /dev/null +++ b/public/img/icons_light_theme/icon_editor.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/public/img/icons_light_theme/icon_viewer.svg b/public/img/icons_light_theme/icon_viewer.svg new file mode 100644 index 00000000000..85d9b7109f4 --- /dev/null +++ b/public/img/icons_light_theme/icon_viewer.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/public/sass/base/_icons.scss b/public/sass/base/_icons.scss index c701cc1249e..bf66d4dc68d 100644 --- a/public/sass/base/_icons.scss +++ b/public/sass/base/_icons.scss @@ -120,6 +120,10 @@ background-image: url('../img/icons_#{$theme-name}_theme/icon_data_sources.svg'); } +.gicon-editor { + background-image: url('../img/icons_#{$theme-name}_theme/icon_editor.svg'); +} + .gicon-folder-new { background-image: url('../img/icons_#{$theme-name}_theme/icon_add_folder.svg'); } @@ -180,6 +184,10 @@ background-image: url('../img/icons_#{$theme-name}_theme/icon_variable.svg'); } +.gicon-viewer { + background-image: url('../img/icons_#{$theme-name}_theme/icon_viewer.svg'); +} + .gicon-zoom-out { background-image: url('../img/icons_#{$theme-name}_theme/icon_zoom_out.svg'); } diff --git a/public/sass/components/_dashboard_settings.scss b/public/sass/components/_dashboard_settings.scss index 873da7f4935..49154b0b485 100644 --- a/public/sass/components/_dashboard_settings.scss +++ b/public/sass/components/_dashboard_settings.scss @@ -64,8 +64,13 @@ background: $page-bg; } - i { - padding-right: 5px; + .gicon { + margin-bottom: 2px; + } + + .fa { + font-size: 17px; + width: 16px; } } diff --git a/public/sass/components/_filter-table.scss b/public/sass/components/_filter-table.scss index 00f9b93dcfd..bfa9fbbbc5a 100644 --- a/public/sass/components/_filter-table.scss +++ b/public/sass/components/_filter-table.scss @@ -85,3 +85,7 @@ } } } +.filter-table__weak-italic { + font-style: italic; + color: $text-color-weak; +} diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 6603cfa072b..7a3046444fe 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -341,19 +341,19 @@ $input-border: 1px solid $input-border-color; margin-right: $gf-form-margin; position: relative; background-color: $input-bg; - padding-right: $input-padding-x; border: $input-border; border-radius: $input-border-radius; &::after { position: absolute; top: 35%; - right: $input-padding-x/2; + right: $input-padding-x; background-color: transparent; color: $input-color; font: normal normal normal $font-size-sm/1 FontAwesome; content: '\f0d7'; pointer-events: none; + font-size: 11px; } .gf-form-input { diff --git a/public/sass/components/_panel_add_panel.scss b/public/sass/components/_panel_add_panel.scss index 51754a54d92..5bfff31a108 100644 --- a/public/sass/components/_panel_add_panel.scss +++ b/public/sass/components/_panel_add_panel.scss @@ -1,11 +1,22 @@ -.add-panel { +.add-panel-container { height: 100%; } +.add-panel { + height: 100%; + + .baron__root { + height: calc(100% - 43px); + } +} + .add-panel__header { - padding: 5px 15px; + padding: 0 15px; display: flex; align-items: center; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; .gicon { font-size: 30px; @@ -23,7 +34,7 @@ .add-panel__title { font-size: $font-size-md; - margin-right: $spacer/2; + margin-right: $spacer*2; } .add-panel__sub-title { @@ -39,7 +50,7 @@ flex-direction: row; flex-wrap: wrap; overflow: auto; - height: calc(100% - 43px); + height: 100%; align-content: flex-start; justify-content: space-around; position: relative; @@ -77,3 +88,16 @@ .add-panel__item-icon { padding: 2px; } + +.add-panel__searchbar { + width: 100%; + margin-bottom: 10px; + margin-top: 7px; +} + +.add-panel__no-panels { + color: $text-color-weak; + font-style: italic; + width: 100%; + padding: 3px 8px; +} diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index e15cd576367..72f3ca3dbbe 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -49,6 +49,7 @@ } .graph-legend { + display: flex; flex: 0 1 auto; max-height: 30%; margin: 0; @@ -56,11 +57,27 @@ padding-top: 6px; position: relative; + // fix for Firefox (white stripe on the right of scrollbar) + width: calc(100% - 1px); + .popover-content { padding: 0; } } +.graph-legend-content { + position: relative; + + // fix for Firefox (white stripe on the right of scrollbar) + width: calc(100% - 1px); +} + +.graph-legend-scroll { + position: relative; + overflow: auto !important; + padding: 1px; +} + .graph-legend-icon { position: relative; padding-right: 4px; @@ -115,8 +132,20 @@ // fix for phantomjs .body--phantomjs { .graph-panel--legend-right { + .graph-legend { + display: inline-block; + } + + .graph-panel__chart { + display: flex; + } + .graph-legend-table { display: table; + + .graph-legend-scroll { + display: table; + } } } } @@ -124,9 +153,9 @@ .graph-legend-table { tbody { display: block; + position: relative; overflow-y: auto; overflow-x: hidden; - height: 100%; padding-bottom: 1px; padding-right: 5px; padding-left: 5px; diff --git a/public/sass/components/_scrollbar.scss b/public/sass/components/_scrollbar.scss index 42818e786f6..78173b73f47 100644 --- a/public/sass/components/_scrollbar.scss +++ b/public/sass/components/_scrollbar.scss @@ -9,6 +9,11 @@ -ms-touch-action: auto; } +// ._scrollbar { +// overflow-x: hidden !important; +// overflow-y: auto; +// } + /* * Scrollbar rail styles */ @@ -101,7 +106,7 @@ opacity: 0.9; } -// Srollbars +// Scrollbars // ::-webkit-scrollbar { @@ -172,3 +177,120 @@ border-top: 1px solid $scrollbarBorder; border-left: 1px solid $scrollbarBorder; } + +// Baron styles + +.baron { + // display: inline-block; // this brakes phantomjs rendering (width becomes 0) + overflow: hidden; +} + +// Fix for side menu on mobile devices +.main-view.baron { + width: unset; +} + +.baron__clipper { + position: relative; + overflow: hidden; +} + +.baron__scroller { + overflow-y: scroll; + -ms-overflow-style: none; + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 0; + border: 0; + padding: 0; + width: 100%; + height: 100%; + -webkit-overflow-scrolling: touch; + /* remove line to customize scrollbar in iOs */ +} + +.baron__scroller::-webkit-scrollbar { + width: 0; + height: 0; +} + +.baron__track { + display: none; + position: absolute; + top: 0; + right: 0; + bottom: 0; +} + +.baron._scrollbar .baron__track { + display: block; +} + +.baron__free { + position: absolute; + top: 0; + bottom: 0; + right: 0; +} + +.baron__bar { + display: none; + position: absolute; + right: 0; + z-index: 1; + // width: 10px; + background: #999; + + // height: 15px; + width: 15px; + transition: background-color 0.2s linear, opacity 0.2s linear; + opacity: 0; +} + +.baron._scrollbar .baron__bar { + display: block; + + @include gradient-vertical($scrollbarBackground, $scrollbarBackground2); + border-radius: 6px; + width: 6px; + /* there must be 'right' for ps__thumb-y */ + right: 0px; + /* please don't change 'position' */ + position: absolute; + + // background-color: transparent; + // opacity: 0.6; + + &:hover, + &:focus { + // background-color: transparent; + opacity: 0.9; + } +} + +.panel-hover-highlight .baron__track .baron__bar { + opacity: 0.6; +} + +.baron._scrolling > .baron__track .baron__bar { + opacity: 0.9; +} + +// fix for phantomjs +.body--phantomjs .baron__track .baron__bar { + opacity: 0 !important; +} + +.baron__control { + display: none; +} + +.baron.panel-content--scrollable { + // Width needs to be set to prevent content width issues + // Set to less than 100% for fixing Firefox issue (white stripe on the right of scrollbar) + width: calc(100% - 2px); + + .baron__scroller { + padding-top: 1px; + } +} diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 99033b90ff1..8338a5d72ae 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -102,14 +102,21 @@ } } +.search-results-scroller { + display: flex; + position: relative; +} + .search-results-container { - height: 100%; display: block; padding: $spacer; position: relative; flex-grow: 10; margin-bottom: 1rem; + // Fix for search scroller in mobile view + height: unset; + .label-tag { margin-left: 6px; font-size: 11px; diff --git a/public/sass/components/_sidemenu.scss b/public/sass/components/_sidemenu.scss index d1372484074..5fdb1a5e32e 100644 --- a/public/sass/components/_sidemenu.scss +++ b/public/sass/components/_sidemenu.scss @@ -149,6 +149,15 @@ color: #ebedf2; } +.sidemenu-subtitle { + padding: 0.5rem 1rem 0.5rem; + font-size: $font-size-sm; + color: $text-color-weak; + border-bottom: 1px solid $dropdownDividerBottom; + margin-bottom: 0.25rem; + white-space: nowrap; +} + li.sidemenu-org-switcher { border-bottom: 1px solid $dropdownDividerBottom; } diff --git a/public/sass/components/_tabs.scss b/public/sass/components/_tabs.scss index 197d5892652..eb3c8ce13f5 100644 --- a/public/sass/components/_tabs.scss +++ b/public/sass/components/_tabs.scss @@ -44,18 +44,16 @@ &::before { display: block; - content: " "; + content: ' '; position: absolute; left: 0; right: 0; height: 2px; top: 0; - background-image: linear-gradient( - to right, - #ffd500 0%, - #ff4400 99%, - #ff4400 100% - ); + background-image: linear-gradient(to right, #ffd500 0%, #ff4400 99%, #ff4400 100%); } } + &.active--panel { + background: $panel-bg !important; + } } diff --git a/public/sass/components/_tags.scss b/public/sass/components/_tags.scss index 9d015f952fe..014d9f0be1e 100644 --- a/public/sass/components/_tags.scss +++ b/public/sass/components/_tags.scss @@ -4,7 +4,7 @@ display: inline-block; padding: 2px 4px; font-size: $font-size-base * 0.846; - font-weight: bold; + font-weight: $font-weight-semi-bold; line-height: 14px; // ensure proper line-height if floated color: $white; vertical-align: baseline; diff --git a/public/sass/layout/_page.scss b/public/sass/layout/_page.scss index 03941a47408..c80d461541e 100644 --- a/public/sass/layout/_page.scss +++ b/public/sass/layout/_page.scss @@ -28,12 +28,20 @@ width: 100%; overflow: auto; height: 100%; + -webkit-overflow-scrolling: touch; &--dashboard { height: calc(100% - 56px); } } +// fix for phantomjs +.body--phantomjs { + .scroll-canvas { + overflow: hidden; + } +} + .page-body { padding-top: $spacer*2; min-height: 500px; diff --git a/public/views/index.template.html b/public/views/index.template.html index 2d408f70f8c..79da1d7179c 100644 --- a/public/views/index.template.html +++ b/public/views/index.template.html @@ -16,7 +16,7 @@ - + @@ -40,7 +40,7 @@
-
+