diff --git a/.bra.toml b/.bra.toml index 15961e1e3fd..aa7a1680adc 100644 --- a/.bra.toml +++ b/.bra.toml @@ -4,6 +4,7 @@ init_cmds = [ ["./bin/grafana-server", "cfg:app_mode=development"] ] watch_all = true +follow_symlinks = true watch_dirs = [ "$WORKDIR/pkg", "$WORKDIR/public/views", diff --git a/.circleci/config.yml b/.circleci/config.yml index a4bb2d67855..a5497e6c7e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -126,7 +126,7 @@ jobs: build-all: docker: - - image: grafana/build-container:1.1.0 + - image: grafana/build-container:1.2.0 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -170,10 +170,11 @@ jobs: - scripts/*.sh - scripts/publish - scripts/build/release_publisher/release_publisher + - scripts/build/publish.sh build: docker: - - image: grafana/build-container:1.1.0 + - image: grafana/build-container:1.2.0 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -232,7 +233,7 @@ jobs: build-enterprise: docker: - - image: grafana/build-container:v0.1 + - image: grafana/build-container:1.2.0 working_directory: /go/src/github.com/grafana/grafana steps: - checkout diff --git a/.gitignore b/.gitignore index 20e8fffb3b1..21083741e14 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ profile.cov /pkg/cmd/grafana-server/grafana-server /pkg/cmd/grafana-server/debug /pkg/extensions +/public/app/extensions debug.test /examples/*/dist /packaging/**/*.rpm @@ -68,7 +69,6 @@ debug.test /vendor/**/*.yml /vendor/**/*_test.go /vendor/**/.editorconfig -/vendor/**/appengine* *.orig /devenv/bulk-dashboards/*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5bcac9b4a..4c276d01594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,30 @@ ### New Features * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) +* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) +* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) ### Minor * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) ### Breaking changes * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) +# 5.3.1 (2018-10-16) + +* **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616) +* **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607) +* **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +* **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553) +* **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628) +* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky) +* **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650) +* **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech) +* **HTTP API**: Fix /api/org/users so that query and limit querystrings works + # 5.3.0 (2018-10-10) * **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495) @@ -63,7 +78,7 @@ * **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476) * **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos) * **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano) -* ****: **: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda) +* **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda) * **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm) * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon) diff --git a/Gopkg.lock b/Gopkg.lock index 041f784f770..4286add847d 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -264,7 +264,7 @@ branch = "master" name = "github.com/hashicorp/yamux" packages = ["."] - revision = "2658be15c5f05e76244154714161f17e3e77de2e" + revision = "7221087c3d281fda5f794e28c2ea4c6e4d5c4558" [[projects]] name = "github.com/inconshreveable/log15" @@ -507,6 +507,8 @@ branch = "master" name = "golang.org/x/crypto" packages = [ + "ed25519", + "ed25519/internal/edwards25519", "md4", "pbkdf2" ] @@ -670,6 +672,16 @@ revision = "e6179049628164864e6e84e973cfb56335748dea" version = "v2.3.2" +[[projects]] + name = "gopkg.in/square/go-jose.v2" + packages = [ + ".", + "cipher", + "json" + ] + revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8" + version = "v2.1.9" + [[projects]] name = "gopkg.in/yaml.v2" packages = ["."] @@ -679,6 +691,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4" + inputs-digest = "6f7f271afd27f78b7d8ebe27436fee72c9925fb82a978bdc57fde44e01f3ca51" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index c5b4b31cb32..e3cbdeabb5d 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -207,3 +207,7 @@ ignored = [ [[constraint]] name = "github.com/VividCortex/mysqlerr" branch = "master" + +[[constraint]] + name = "gopkg.in/square/go-jose.v2" + version = "2.1.9" diff --git a/README.md b/README.md index 133d9e50d07..1179385d10c 100644 --- a/README.md +++ b/README.md @@ -138,5 +138,5 @@ plugin development. ## License -Grafana is distributed under Apache 2.0 License. +Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md). diff --git a/appveyor.yml b/appveyor.yml index 52f23162033..4bbd3668e19 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -5,7 +5,7 @@ os: Windows Server 2012 R2 clone_folder: c:\gopath\src\github.com\grafana\grafana environment: - nodejs_version: "6" + nodejs_version: "8" GOPATH: C:\gopath GOVERSION: 1.11 diff --git a/devenv/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml index 3434f5d09b9..e1727807d41 100644 --- a/devenv/docker/blocks/influxdb/docker-compose.yaml +++ b/devenv/docker/blocks/influxdb/docker-compose.yaml @@ -6,7 +6,7 @@ - "8083:8083" - "8086:8086" volumes: - - ./blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf + - ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf fake-influxdb-data: image: grafana/fake-data-gen diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 862bee3dfe5..8916b2bf6e3 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -156,9 +156,9 @@ Since not all datasources have the same configuration settings we only have the | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. | | graphiteVersion | string | Graphite | Graphite version | | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source | -| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) | -| timeField | string | Elastic | Which field that should be used as timestamp | -| interval | string | Elastic | Index date time format | +| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) | +| timeField | string | Elasticsearch | Which field that should be used as timestamp | +| interval | string | Elasticsearch | Index date time format | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | @@ -166,6 +166,7 @@ Since not all datasources have the same configuration settings we only have the | tsdbVersion | string | OpenTSDB | Version | | tsdbResolution | string | OpenTSDB | Resolution | | sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' | +| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' | | postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 | | timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension | | maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) | diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index a5b7f4264e0..307af1ee15e 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -128,7 +128,7 @@ Example json body: In DingTalk PC Client: -1. Click "more" icon on left bottom of the panel. +1. Click "more" icon on upper right of the panel. 2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage". diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 5bb5c4cd753..6fa6531fc98 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -17,6 +17,9 @@ can find examples using Okta, BitBucket, OneLogin and Azure. This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`. +You may have to set the `root_url` option of `[server]` for the callback URL to be +correct. For example in case you are serving Grafana behind a proxy. + Example config: ```bash diff --git a/docs/sources/auth/github.md b/docs/sources/auth/github.md index 263b3cc5d4d..b4ffc0fc2d4 100644 --- a/docs/sources/auth/github.md +++ b/docs/sources/auth/github.md @@ -46,6 +46,9 @@ team_ids = allowed_organizations = ``` +You may have to set the `root_url` option of `[server]` for the callback URL to be +correct. For example in case you are serving Grafana behind a proxy. + Restart the Grafana back-end. You should now see a GitHub login button on the login page. You can now login or sign up with your GitHub accounts. diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index 32910167f16..e3a450f9fc7 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -58,6 +58,9 @@ api_url = https://gitlab.com/api/v4 allowed_groups = ``` +You may have to set the `root_url` option of `[server]` for the callback URL to be +correct. For example in case you are serving Grafana behind a proxy. + Restart the Grafana backend for your changes to take effect. If you use your own instance of GitLab instead of `gitlab.com`, adjust diff --git a/docs/sources/auth/google.md b/docs/sources/auth/google.md index eeb78044d3e..f7faf1a1097 100644 --- a/docs/sources/auth/google.md +++ b/docs/sources/auth/google.md @@ -45,6 +45,9 @@ allowed_domains = mycompany.com mycompany.org allow_sign_up = true ``` +You may have to set the `root_url` option of `[server]` for the callback URL to be +correct. For example in case you are serving Grafana behind a proxy. + Restart the Grafana back-end. You should now see a Google login button on the login page. You can now login or sign up with your Google accounts. The `allowed_domains` option is optional, and domains were separated by space. diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md index 4a3478b161a..cd191f14273 100644 --- a/docs/sources/features/datasources/mssql.md +++ b/docs/sources/features/datasources/mssql.md @@ -32,6 +32,7 @@ Name | Description *Database* | Name of your MSSQL database. *User* | Database user's login/username *Password* | Database user's password +*Encrypt* | This option determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server, default `false` (Grafana v5.4+). *Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+). *Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+). *Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+). @@ -72,8 +73,8 @@ Make sure the user does not get any unwanted privileges from the public role. ### Known Issues -MSSQL 2008 and 2008 R2 engine cannot handle login records when SSL encryption is not disabled. Due to this you may receive an `Login error: EOF` error when trying to create your datasource. -To fix MSSQL 2008 R2 issue, install MSSQL 2008 R2 Service Pack 2. To fix MSSQL 2008 issue, install Microsoft MSSQL 2008 Service Pack 3 and Cumulative update package 3 for MSSQL 2008 SP3. +If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect. +If possible, we recommend you to use the latest service pack available for optimal compatibility. ## Query Editor diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index 291e473114c..cea86e96faf 100644 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -22,7 +22,7 @@ Grafana ships with built-in support for Google Stackdriver. Just add it as a dat 1. Open the side menu by clicking the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. 3. Click the `+ Add data source` button in the top header. -4. Select `Stackdriver` from the *Type* dropdown. +4. Select `Stackdriver` from the _Type_ dropdown. 5. Upload or paste in the Service Account Key file. See below for steps on how to create a Service Account Key file. > NOTE: If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization. @@ -43,36 +43,46 @@ To authenticate with the Stackdriver API, you need to create a Google Cloud Plat The following APIs need to be enabled first: -- [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com) -- [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com) +* [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com) +* [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com) Click on the links above and click the `Enable` button: -![Enable GCP APIs](/img/docs/v54/stackdriver_enable_api.png) +{{< docs-imagebox img="/img/docs/v53/stackdriver_enable_api.png" class="docs-image--no-shadow" caption="Enable GCP APIs" >}} #### Create a GCP Service Account for a Project 1. Navigate to the [APIs & Services Credentials page](https://console.cloud.google.com/apis/credentials). 2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option. - ![Create service account button](/img/docs/v54/stackdriver_create_service_account_button.png) + {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}} + 3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option: - ![Create service account key](/img/docs/v54/stackdriver_create_service_account_key.png) + {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}} + 4. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown: - ![Choose role](/img/docs/v54/stackdriver_service_account_choose_role.png) + {{< docs-imagebox img="/img/docs/v53/stackdriver_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}} + 5. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Stackdriver data. 6. Upload it to Grafana on the datasource Configuration page. You can either upload the file or paste in the contents of the file. - - ![Choose role](/img/docs/v54/stackdriver_grafana_upload_key.png) + + {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}} + 7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file! - - ![Choose role](/img/docs/v54/stackdriver_grafana_key_uploaded.png) + + {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}} ## Metric Query Editor -Choose a metric from the `Metric` dropdown. +{{< docs-imagebox img="/img/docs/v53/stackdriver_query_editor.png" max-width= "400px" class="docs-image--right" >}} + +The Stackdriver query editor allows you to select metrics, group/aggregate by labels and by time, and use filters to specify which time series you want in the results. + +Begin by choosing a `Service` and then a metric from the `Metric` dropdown. Use the plus and minus icons in the filter and group by sections to add/remove filters or group by clauses. + +Stackdriver metrics can be of different kinds (GAUGE, DELTA, CUMULATIVE) and these kinds have support for different aggregation options (reducers and aligners). The Grafana query editor shows the list of available aggregation methods for a selected metric and sets a default reducer and aligner when you select the metric. Units for the Y-axis are also automatically selected by the query editor. ### Filter @@ -80,7 +90,7 @@ To add a filter, click the plus icon and choose a field to filter by and enter a #### Simple wildcards -When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions. +When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions. #### Regular expressions @@ -97,9 +107,9 @@ The `Aligner` field allows you to align multiple time series after the same grou The `Alignment Period` groups a metric by time if an aggregation is chosen. The default is to use the GCP Stackdriver default groupings (which allows you to compare graphs in Grafana with graphs in the Stackdriver UI). The option is called `Stackdriver auto` and the defaults are: -- 1m for time ranges < 23 hours -- 5m for time ranges >= 23 hours and < 6 days -- 1h for time ranges >= 6 days +* 1m for time ranges < 23 hours +* 5m for time ranges >= 23 hours and < 6 days +* 1h for time ranges >= 6 days The other automatic option is `Grafana auto`. This will automatically set the group by time depending on the time range chosen and the width of the graph panel. Read more about the details [here](http://docs.grafana.org/reference/templating/#the-interval-variable). @@ -151,15 +161,34 @@ Writing variable queries is not supported yet. There are two syntaxes: -- `$` Example: rate(http_requests_total{job=~"$job"}[5m]) -- `[[varname]]` Example: rate(http_requests_total{job=~"[[job]]"}[5m]) +* `$` Example: `metric.label.$metric_label` +* `[[varname]]` Example: `metric.label.[[metric_label]]` -Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the *Multi-value* or *Include all value* options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`. +Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`. ## Annotations +{{< docs-imagebox img="/img/docs/v53/stackdriver_annotations_query_editor.png" max-width= "400px" class="docs-image--right" >}} + [Annotations]({{< relref "reference/annotations.md" >}}) allows you to overlay rich event information on top of graphs. You add annotation -queries via the Dashboard menu / Annotations view. +queries via the Dashboard menu / Annotations view. Annotation rendering is expensive so it is important to limit the number of rows returned. There is no support for showing Stackdriver annotations and events yet but it works well with [custom metrics](https://cloud.google.com/monitoring/custom-metrics/) in Stackdriver. + +With the query editor for annotations, you can select a metric and filters. The `Title` and `Text` fields support templating and can use data returned from the query. For example, the Title field could have the following text: + +`{{metric.type}} has value: {{metric.value}}` + +Example Result: `monitoring.googleapis.com/uptime_check/http_status has this value: 502` + +### Patterns for the Annotation Query Editor + +| Alias Pattern Format | Description | Alias Pattern Example | Example Result | +| ------------------------ | -------------------------------- | -------------------------------- | ------------------------------------------------- | +| `{{metric.value}}` | value of the metric/point | `{{metric.value}}` | `555` | +| `{{metric.type}}` | returns the full Metric Type | `{{metric.type}}` | `compute.googleapis.com/instance/cpu/utilization` | +| `{{metric.name}}` | returns the metric name part | `{{metric.name}}` | `instance/cpu/utilization` | +| `{{metric.service}}` | returns the service part | `{{metric.service}}` | `compute` | +| `{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod` | +| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b` | ## Configure the Datasource with Provisioning @@ -173,6 +202,7 @@ apiVersion: 1 datasources: - name: Stackdriver type: stackdriver + access: proxy jsonData: tokenUri: https://oauth2.googleapis.com/token clientEmail: stackdriver@myproject.iam.gserviceaccount.com diff --git a/docs/sources/index.md b/docs/sources/index.md index da977b73e0c..e9a900d93f1 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]

Provisioning

A guide to help you automate your Grafana setup & configuration.

- }}" class="nav-cards__item nav-cards__item--guide"> -

What's new in v5.2

-

Article on all the new cool features and enhancements in v5.2

+
}}" class="nav-cards__item nav-cards__item--guide"> +

What's new in v5.3

+

Article on all the new cool features and enhancements in v5.3

}}" class="nav-cards__item nav-cards__item--guide">

Screencasts

@@ -88,9 +88,13 @@ aliases = ["v1.1", "guides/reference/admin"]
Prometheus
- }}" class="nav-cards__item nav-cards__item--ds"> - -
OpenTSDB
+
}}" class="nav-cards__item nav-cards__item--ds"> + +
Google Stackdriver
+
+ }}" class="nav-cards__item nav-cards__item--ds"> + +
Cloudwatch
}}" class="nav-cards__item nav-cards__item--ds"> @@ -100,8 +104,12 @@ aliases = ["v1.1", "guides/reference/admin"]
Postgres
- }}" class="nav-cards__item nav-cards__item--ds"> - -
Cloudwatch
+
}}" class="nav-cards__item nav-cards__item--ds"> + +
Microsoft SQL Server
+
+ }}" class="nav-cards__item nav-cards__item--ds"> + +
OpenTSDB
diff --git a/docs/sources/plugins/developing/development.md b/docs/sources/plugins/developing/development.md index f2e70a50c6a..48410b06732 100644 --- a/docs/sources/plugins/developing/development.md +++ b/docs/sources/plugins/developing/development.md @@ -10,7 +10,7 @@ weight = 1 # Developer Guide -You can extend Grafana by writing your own plugins and then share then with other users in [our plugin repository](https://grafana.com/plugins). +You can extend Grafana by writing your own plugins and then share them with other users in [our plugin repository](https://grafana.com/plugins). ## Short version @@ -33,7 +33,7 @@ There are two blog posts about authoring a plugin that might also be of interest ## What languages? Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since -we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. +we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo if you choose one of those languages. ## Buildscript @@ -60,7 +60,6 @@ and [apps]({{< relref "apps.md" >}}) plugins in the documentation. The Grafana SDK is quite small so far and can be found here: - [SDK file in Grafana](https://github.com/grafana/grafana/blob/master/public/app/plugins/sdk.ts) -- [SDK Readme](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) The SDK contains three different plugin classes: PanelCtrl, MetricsPanelCtrl and QueryCtrl. For plugins of the panel type, the module.js file should export one of these. There are some extra classes for [data sources]({{< relref "datasources.md" >}}). diff --git a/latest.json b/latest.json index 3b5a9e0c227..4355e9a64b7 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "5.3.0", - "testing": "5.3.0" + "stable": "5.3.1", + "testing": "5.3.1" } diff --git a/package.json b/package.json index 5577579e0e5..0517c0d5ba5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/react": "^16.4.14", "@types/react-custom-scrollbars": "^4.0.5", "@types/react-dom": "^16.0.7", + "@types/react-select": "^2.0.4", "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", "axios": "^0.17.1", @@ -79,14 +80,14 @@ "style-loader": "^0.21.0", "systemjs": "0.20.19", "systemjs-plugin-css": "^0.1.36", - "ts-jest": "^23.1.4", + "ts-jest": "^23.10.4", "ts-loader": "^5.1.0", "tslib": "^1.9.3", "tslint": "^5.8.0", "tslint-loader": "^3.5.3", "typescript": "^3.0.3", "uglifyjs-webpack-plugin": "^1.2.7", - "webpack": "^4.8.0", + "webpack": "4.19.1", "webpack-bundle-analyzer": "^2.9.0", "webpack-cleanup-plugin": "^0.5.1", "webpack-cli": "^2.1.4", @@ -157,7 +158,7 @@ "react-highlight-words": "^0.10.0", "react-popper": "^0.7.5", "react-redux": "^5.0.7", - "react-select": "^1.1.0", + "react-select": "2.1.0", "react-sizeme": "^2.3.6", "react-transition-group": "^2.2.1", "redux": "^4.0.0", diff --git a/pkg/api/api.go b/pkg/api/api.go index 39b332aeb9f..5c5596d5da2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -10,10 +10,10 @@ import ( ) func (hs *HTTPServer) registerRoutes() { - reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) - reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) - reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) + reqSignedIn := middleware.ReqSignedIn + reqGrafanaAdmin := middleware.ReqGrafanaAdmin + reqEditorRole := middleware.ReqEditorRole + reqOrgAdmin := middleware.ReqOrgAdmin redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota @@ -22,66 +22,66 @@ func (hs *HTTPServer) registerRoutes() { r := hs.RouteRegister // not logged in views - r.Get("/", reqSignedIn, Index) + r.Get("/", reqSignedIn, hs.Index) r.Get("/logout", Logout) r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) r.Get("/login/:name", quota("session"), OAuthLogin) - r.Get("/login", LoginView) - r.Get("/invite/:code", Index) + r.Get("/login", hs.LoginView) + r.Get("/invite/:code", hs.Index) // authed views - r.Get("/profile/", reqSignedIn, Index) - r.Get("/profile/password", reqSignedIn, Index) - r.Get("/profile/switch-org/:id", reqSignedIn, ChangeActiveOrgAndRedirectToHome) - r.Get("/org/", reqSignedIn, Index) - r.Get("/org/new", reqSignedIn, Index) - r.Get("/datasources/", reqSignedIn, Index) - r.Get("/datasources/new", reqSignedIn, Index) - r.Get("/datasources/edit/*", reqSignedIn, Index) - r.Get("/org/users", reqSignedIn, Index) - r.Get("/org/users/new", reqSignedIn, Index) - r.Get("/org/users/invite", reqSignedIn, Index) - r.Get("/org/teams", reqSignedIn, Index) - r.Get("/org/teams/*", reqSignedIn, Index) - r.Get("/org/apikeys/", reqSignedIn, Index) - r.Get("/dashboard/import/", reqSignedIn, Index) - r.Get("/configuration", reqGrafanaAdmin, Index) - r.Get("/admin", reqGrafanaAdmin, Index) - r.Get("/admin/settings", reqGrafanaAdmin, Index) - r.Get("/admin/users", reqGrafanaAdmin, Index) - r.Get("/admin/users/create", reqGrafanaAdmin, Index) - r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index) - r.Get("/admin/orgs", reqGrafanaAdmin, Index) - r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index) - r.Get("/admin/stats", reqGrafanaAdmin, Index) + r.Get("/profile/", reqSignedIn, hs.Index) + r.Get("/profile/password", reqSignedIn, hs.Index) + r.Get("/profile/switch-org/:id", reqSignedIn, hs.ChangeActiveOrgAndRedirectToHome) + r.Get("/org/", reqSignedIn, hs.Index) + r.Get("/org/new", reqSignedIn, hs.Index) + r.Get("/datasources/", reqSignedIn, hs.Index) + r.Get("/datasources/new", reqSignedIn, hs.Index) + r.Get("/datasources/edit/*", reqSignedIn, hs.Index) + r.Get("/org/users", reqSignedIn, hs.Index) + r.Get("/org/users/new", reqSignedIn, hs.Index) + r.Get("/org/users/invite", reqSignedIn, hs.Index) + r.Get("/org/teams", reqSignedIn, hs.Index) + r.Get("/org/teams/*", reqSignedIn, hs.Index) + r.Get("/org/apikeys/", reqSignedIn, hs.Index) + r.Get("/dashboard/import/", reqSignedIn, hs.Index) + r.Get("/configuration", reqGrafanaAdmin, hs.Index) + r.Get("/admin", reqGrafanaAdmin, hs.Index) + r.Get("/admin/settings", reqGrafanaAdmin, hs.Index) + r.Get("/admin/users", reqGrafanaAdmin, hs.Index) + r.Get("/admin/users/create", reqGrafanaAdmin, hs.Index) + r.Get("/admin/users/edit/:id", reqGrafanaAdmin, hs.Index) + r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) + r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) + r.Get("/admin/stats", reqGrafanaAdmin, hs.Index) - r.Get("/styleguide", reqSignedIn, Index) + r.Get("/styleguide", reqSignedIn, hs.Index) - r.Get("/plugins", reqSignedIn, Index) - r.Get("/plugins/:id/edit", reqSignedIn, Index) - r.Get("/plugins/:id/page/:page", reqSignedIn, Index) + r.Get("/plugins", reqSignedIn, hs.Index) + r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) + r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index) - r.Get("/d/:uid/:slug", reqSignedIn, Index) - r.Get("/d/:uid", reqSignedIn, Index) - r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, Index) - r.Get("/dashboard/script/*", reqSignedIn, Index) - r.Get("/dashboard-solo/snapshot/*", Index) - r.Get("/d-solo/:uid/:slug", reqSignedIn, Index) - r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, Index) - r.Get("/dashboard-solo/script/*", reqSignedIn, Index) - r.Get("/import/dashboard", reqSignedIn, Index) - r.Get("/dashboards/", reqSignedIn, Index) - r.Get("/dashboards/*", reqSignedIn, Index) + r.Get("/d/:uid/:slug", reqSignedIn, hs.Index) + r.Get("/d/:uid", reqSignedIn, hs.Index) + r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, hs.Index) + r.Get("/dashboard/script/*", reqSignedIn, hs.Index) + r.Get("/dashboard-solo/snapshot/*", hs.Index) + r.Get("/d-solo/:uid/:slug", reqSignedIn, hs.Index) + r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, hs.Index) + r.Get("/dashboard-solo/script/*", reqSignedIn, hs.Index) + r.Get("/import/dashboard", reqSignedIn, hs.Index) + r.Get("/dashboards/", reqSignedIn, hs.Index) + r.Get("/dashboards/*", reqSignedIn, hs.Index) - r.Get("/explore", reqEditorRole, Index) + r.Get("/explore", reqEditorRole, hs.Index) - r.Get("/playlists/", reqSignedIn, Index) - r.Get("/playlists/*", reqSignedIn, Index) - r.Get("/alerting/", reqSignedIn, Index) - r.Get("/alerting/*", reqSignedIn, Index) + r.Get("/playlists/", reqSignedIn, hs.Index) + r.Get("/playlists/*", reqSignedIn, hs.Index) + r.Get("/alerting/", reqSignedIn, hs.Index) + r.Get("/alerting/*", reqSignedIn, hs.Index) // sign up - r.Get("/signup", Index) + r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) @@ -91,15 +91,15 @@ func (hs *HTTPServer) registerRoutes() { r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) // reset password - r.Get("/user/password/send-reset-email", Index) - r.Get("/user/password/reset", Index) + r.Get("/user/password/send-reset-email", hs.Index) + r.Get("/user/password/reset", hs.Index) r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail)) r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword)) // dashboard snapshots - r.Get("/dashboard/snapshot/*", Index) - r.Get("/dashboard/snapshots/", reqSignedIn, Index) + r.Get("/dashboard/snapshot/*", hs.Index) + r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api for dashboard snapshots r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index d65598f6e5e..02248334b9c 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -6,6 +6,7 @@ import ( "os" "path" + "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/api/dtos" @@ -251,8 +252,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { return Error(403, err.Error(), err) } - if err == m.ErrDashboardContainsInvalidAlertData { - return Error(500, "Invalid alert data. Cannot save dashboard", err) + if validationErr, ok := err.(alerting.ValidationError); ok { + return Error(422, validationErr.Error(), nil) } if err != nil { diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 283a9b5f12c..2726623c242 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" @@ -725,7 +726,7 @@ func TestDashboardApiEndpoint(t *testing.T) { {SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412}, {SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400}, {SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400}, - {SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500}, + {SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: 422}, {SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500}, {SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500}, {SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400}, diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index 697d0a35a08..c037831f341 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { return &AlertNotification{ - Id: notification.Id, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Created: notification.Created, - Updated: notification.Updated, - Frequency: formatShort(notification.Frequency), - SendReminder: notification.SendReminder, - Settings: notification.Settings, + Id: notification.Id, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Created: notification.Created, + Updated: notification.Updated, + Frequency: formatShort(notification.Frequency), + SendReminder: notification.SendReminder, + DisableResolveMessage: notification.DisableResolveMessage, + Settings: notification.Settings, } } type AlertNotification struct { - Id int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsDefault bool `json:"isDefault"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Settings *simplejson.Json `json:"settings"` + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsDefault bool `json:"isDefault"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Settings *simplejson.Json `json:"settings"` } type AlertTestCommand struct { @@ -100,11 +102,12 @@ type EvalMatch struct { } type NotificationTestCommand struct { - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Settings *simplejson.Json `json:"settings"` } type PauseAlertCommand struct { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 432d6a18369..858b3c5a8c5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/setting" ) @@ -52,6 +53,7 @@ type HTTPServer struct { Bus bus.Bus `inject:""` RenderService rendering.Service `inject:""` Cfg *setting.Cfg `inject:""` + HooksService *hooks.HooksService `inject:""` } func (hs *HTTPServer) Init() error { @@ -184,7 +186,7 @@ func (hs *HTTPServer) applyRoutes() { // then custom app proxy routes hs.initAppPluginRoutes(hs.macaron) // lastly not found route - hs.macaron.NotFound(NotFoundHandler) + hs.macaron.NotFound(hs.NotFoundHandler) } func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { diff --git a/pkg/api/index.go b/pkg/api/index.go index 1b73acd8829..9f867d51cad 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -17,7 +17,7 @@ const ( darkName = "dark" ) -func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { +func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { settings, err := getFrontendSettingsMap(c) if err != nil { return nil, err @@ -350,11 +350,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { }, }) + hs.HooksService.RunIndexDataHooks(&data) return &data, nil } -func Index(c *m.ReqContext) { - data, err := setIndexViewData(c) +func (hs *HTTPServer) Index(c *m.ReqContext) { + data, err := hs.setIndexViewData(c) if err != nil { c.Handle(500, "Failed to get settings", err) return @@ -362,13 +363,13 @@ func Index(c *m.ReqContext) { c.HTML(200, "index", data) } -func NotFoundHandler(c *m.ReqContext) { +func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(404, "Not found", nil) return } - data, err := setIndexViewData(c) + data, err := hs.setIndexViewData(c) if err != nil { c.Handle(500, "Failed to get settings", err) return diff --git a/pkg/api/login.go b/pkg/api/login.go index 632d04e37f1..1083f89adfd 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -17,8 +17,8 @@ const ( ViewIndex = "index" ) -func LoginView(c *m.ReqContext) { - viewData, err := setIndexViewData(c) +func (hs *HTTPServer) LoginView(c *m.ReqContext) { + viewData, err := hs.setIndexViewData(c) if err != nil { c.Handle(500, "Failed to get settings", err) return diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 4e2ed36431e..e750662c764 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -45,7 +45,7 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { // GET /api/org/users func GetOrgUsersForCurrentOrg(c *m.ReqContext) Response { - return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit")) + return getOrgUsersHelper(c.OrgId, c.Query("query"), c.QueryInt("limit")) } // GET /api/orgs/:orgId/users @@ -102,26 +102,32 @@ func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response { // DELETE /api/org/users/:userId func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response { - userID := c.ParamsInt64(":userId") - return removeOrgUserHelper(c.OrgId, userID) + return removeOrgUserHelper(&m.RemoveOrgUserCommand{ + UserId: c.ParamsInt64(":userId"), + OrgId: c.OrgId, + ShouldDeleteOrphanedUser: true, + }) } // DELETE /api/orgs/:orgId/users/:userId func RemoveOrgUser(c *m.ReqContext) Response { - userID := c.ParamsInt64(":userId") - orgID := c.ParamsInt64(":orgId") - return removeOrgUserHelper(orgID, userID) + return removeOrgUserHelper(&m.RemoveOrgUserCommand{ + UserId: c.ParamsInt64(":userId"), + OrgId: c.ParamsInt64(":orgId"), + }) } -func removeOrgUserHelper(orgID int64, userID int64) Response { - cmd := m.RemoveOrgUserCommand{OrgId: orgID, UserId: userID} - - if err := bus.Dispatch(&cmd); err != nil { +func removeOrgUserHelper(cmd *m.RemoveOrgUserCommand) Response { + if err := bus.Dispatch(cmd); err != nil { if err == m.ErrLastOrgAdmin { return Error(400, "Cannot remove last organization admin", nil) } return Error(500, "Failed to remove user from organization", err) } + if cmd.UserWasDeleted { + return Success("User deleted") + } + return Success("User removed from organization") } diff --git a/pkg/api/user.go b/pkg/api/user.go index 4b916202e65..7116ad83f3f 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -177,17 +177,17 @@ func UserSetUsingOrg(c *m.ReqContext) Response { } // GET /profile/switch-org/:id -func ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) { +func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) { orgID := c.ParamsInt64(":id") if !validateUsingOrg(c.UserId, orgID) { - NotFoundHandler(c) + hs.NotFoundHandler(c) } cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID} if err := bus.Dispatch(&cmd); err != nil { - NotFoundHandler(c) + hs.NotFoundHandler(c) } c.Redirect(setting.AppSubUrl + "/") diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 84325bae808..06c07a2887c 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -100,7 +100,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) { sighupChan := make(chan os.Signal, 1) signal.Notify(sighupChan, syscall.SIGHUP) - signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) for { select { diff --git a/pkg/extensions/main.go b/pkg/extensions/main.go index 6e3461da8a8..1d8bbce03f3 100644 --- a/pkg/extensions/main.go +++ b/pkg/extensions/main.go @@ -1,3 +1,7 @@ package extensions +import ( + _ "gopkg.in/square/go-jose.v2" +) + var IsEnterprise bool = false diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 475dce089b1..3e83a60f94b 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -14,6 +14,13 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var ( + ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) + ReqSignedIn = Auth(&AuthOptions{ReqSignedIn: true}) + ReqEditorRole = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) + ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) +) + func GetContextHandler() macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index 2128b469fa4..e0fd12937ed 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -23,38 +23,41 @@ var ( ) type AlertNotification struct { - Id int64 `json:"id"` - OrgId int64 `json:"-"` - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency time.Duration `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Id int64 `json:"id"` + OrgId int64 `json:"-"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency time.Duration `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } type CreateAlertNotificationCommand struct { - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` OrgId int64 `json:"-"` Result *AlertNotification } type UpdateAlertNotificationCommand struct { - Id int64 `json:"id" binding:"Required"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings" binding:"Required"` + Id int64 `json:"id" binding:"Required"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings" binding:"Required"` OrgId int64 `json:"-"` Result *AlertNotification diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 4b84d840113..e8aebb1d1f4 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -21,7 +21,6 @@ var ( 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") diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 9231d18cfd6..b6ecd924e9a 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -72,8 +72,10 @@ type OrgUser struct { // COMMANDS type RemoveOrgUserCommand struct { - UserId int64 - OrgId int64 + UserId int64 + OrgId int64 + ShouldDeleteOrphanedUser bool + UserWasDeleted bool } type AddOrgUserCommand struct { diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go index 8d7ca57f010..eef593d39e2 100644 --- a/pkg/services/alerting/conditions/evaluator.go +++ b/pkg/services/alerting/conditions/evaluator.go @@ -2,6 +2,7 @@ package conditions import ( "encoding/json" + "fmt" "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" @@ -31,12 +32,12 @@ type ThresholdEvaluator struct { func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) { params := model.Get("params").MustArray() if len(params) == 0 { - return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"} + return nil, fmt.Errorf("Evaluator missing threshold parameter") } firstParam, ok := params[0].(json.Number) if !ok { - return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"} + return nil, fmt.Errorf("Evaluator has invalid parameter") } defaultEval := &ThresholdEvaluator{Type: typ} @@ -107,7 +108,7 @@ func (e *RangedEvaluator) Eval(reducedValue null.Float) bool { func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) { typ := model.Get("type").MustString() if typ == "" { - return nil, alerting.ValidationError{Reason: "Evaluator missing type property"} + return nil, fmt.Errorf("Evaluator missing type property") } if inSlice(typ, defaultTypes) { @@ -122,7 +123,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) { return &NoValueEvaluator{}, nil } - return nil, alerting.ValidationError{Reason: "Evaluator invalid evaluator type: " + typ} + return nil, fmt.Errorf("Evaluator invalid evaluator type: %s", typ) } func inSlice(a string, list []string) bool { diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index 229092e217b..edfab2dedee 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -82,8 +82,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, if collapsed && collapsedJSON.MustBool() { // extract alerts from sub panels for collapsed panels - alertSlice, err := e.getAlertFromPanels(panel, - validateAlertFunc) + alertSlice, err := e.getAlertFromPanels(panel, validateAlertFunc) if err != nil { return nil, err } @@ -100,7 +99,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, panelID, err := panel.Get("id").Int64() if err != nil { - return nil, fmt.Errorf("panel id is required. err %v", err) + return nil, ValidationError{Reason: "A numeric panel id property is missing"} } // backward compatibility check, can be removed later @@ -146,7 +145,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, datasource, err := e.lookupDatasourceID(dsName) if err != nil { - return nil, err + e.log.Debug("Error looking up datasource", "error", err) + return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)} } jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id) @@ -167,8 +167,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, } if !validateAlertFunc(alert) { - e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId) - return nil, m.ErrDashboardContainsInvalidAlertData + return nil, ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelId)} } alerts = append(alerts, alert) diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index c7212e48174..e2dc01a1181 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -258,7 +258,7 @@ func TestAlertRuleExtraction(t *testing.T) { Convey("Should fail on save", func() { _, err := extractor.GetAlerts() - So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData) + So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") }) }) }) diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 96294f0624f..040d0991861 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -27,6 +27,7 @@ type Notifier interface { GetNotifierId() int64 GetIsDefault() bool GetSendReminder() bool + GetDisableResolveMessage() bool GetFrequency() time.Duration } diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index fbade2eccac..d141d6cd257 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" ) @@ -15,13 +14,14 @@ const ( ) type NotifierBase struct { - Name string - Type string - Id int64 - IsDeault bool - UploadImage bool - SendReminder bool - Frequency time.Duration + Name string + Type string + Id int64 + IsDeault bool + UploadImage bool + SendReminder bool + DisableResolveMessage bool + Frequency time.Duration log log.Logger } @@ -34,14 +34,15 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } return NotifierBase{ - Id: model.Id, - Name: model.Name, - IsDeault: model.IsDefault, - Type: model.Type, - UploadImage: uploadImage, - SendReminder: model.SendReminder, - Frequency: model.Frequency, - log: log.New("alerting.notifier." + model.Name), + Id: model.Id, + Name: model.Name, + IsDeault: model.IsDefault, + Type: model.Type, + UploadImage: uploadImage, + SendReminder: model.SendReminder, + DisableResolveMessage: model.DisableResolveMessage, + Frequency: model.Frequency, + log: log.New("alerting.notifier." + model.Name), } } @@ -83,6 +84,11 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC } } + // Do not notify when state is OK if DisableResolveMessage is set to true + if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage { + return false + } + return true } @@ -106,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool { return n.SendReminder } +func (n *NotifierBase) GetDisableResolveMessage() bool { + return n.DisableResolveMessage +} + func (n *NotifierBase) GetFrequency() time.Duration { return n.Frequency } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 5e46d3ad72e..5062828cb4f 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) { base := NewNotifierBase(model) So(base.UploadImage, ShouldBeTrue) }) + + Convey("default value should be false for backwards compatibility", func() { + base := NewNotifierBase(model) + So(base.DisableResolveMessage, ShouldBeFalse) + }) }) } diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 5492de45d39..6c47c92972c 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -127,7 +127,13 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval var err error imageFile, err = os.Open(evalContext.ImageOnDiskPath) - defer imageFile.Close() + defer func() { + err := imageFile.Close() + if err != nil { + log.Error2("Could not close Telegram inline image.", "err", err) + } + }() + if err != nil { return nil, err } diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index d13924c2a17..999611f15c4 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -36,13 +36,13 @@ type ValidationError struct { } func (e ValidationError) Error() string { - extraInfo := "" + extraInfo := e.Reason if e.Alertid != 0 { extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid) } if e.PanelId != 0 { - extraInfo = fmt.Sprintf("%s PanelId: %v ", extraInfo, e.PanelId) + extraInfo = fmt.Sprintf("%s PanelId: %v", extraInfo, e.PanelId) } if e.DashboardId != 0 { @@ -50,10 +50,10 @@ func (e ValidationError) Error() string { } if e.Err != nil { - return fmt.Sprintf("%s %s%s", e.Err.Error(), e.Reason, extraInfo) + return fmt.Sprintf("Alert validation error: %s%s", e.Err.Error(), extraInfo) } - return fmt.Sprintf("Failed to extract alert.Reason: %s %s", e.Reason, extraInfo) + return fmt.Sprintf("Alert validation error: %s", extraInfo) } var ( @@ -128,7 +128,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) { } if len(model.Conditions) == 0 { - return nil, fmt.Errorf("Alert is missing conditions") + return nil, ValidationError{Reason: "Alert is missing conditions"} } return model, nil diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 521601a358b..c15ae8ef36c 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -73,7 +73,7 @@ func (srv *CleanUpService) cleanUpTmpFiles() { } } - srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files)) + srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "kept", len(files)) } func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool { diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 278421e6be7..8eb7f4a6e72 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" @@ -25,7 +26,9 @@ type DashboardProvisioningService interface { // NewService factory for creating a new dashboard service var NewService = func() DashboardService { - return &dashboardServiceImpl{} + return &dashboardServiceImpl{ + log: log.New("dashboard-service"), + } } // NewProvisioningService factory for creating a new dashboard provisioning service @@ -45,6 +48,7 @@ type SaveDashboardDTO struct { type dashboardServiceImpl struct { orgId int64 user *models.SignedInUser + log log.Logger } func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { @@ -89,7 +93,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, } if err := bus.Dispatch(&validateAlertsCmd); err != nil { - return nil, models.ErrDashboardContainsInvalidAlertData + return nil, err } } diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index f9d487f625c..b8300a5af8d 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -117,12 +117,12 @@ func TestDashboardService(t *testing.T) { }) bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { - return errors.New("error") + return errors.New("Alert validation error") }) dto.Dashboard = models.NewDashboard("Dash") _, err := service.SaveDashboard(dto) - So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData) + So(err.Error(), ShouldEqual, "Alert validation error") }) }) diff --git a/pkg/services/hooks/hooks.go b/pkg/services/hooks/hooks.go new file mode 100644 index 00000000000..c51650cf6c9 --- /dev/null +++ b/pkg/services/hooks/hooks.go @@ -0,0 +1,30 @@ +package hooks + +import ( + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/registry" +) + +type IndexDataHook func(indexData *dtos.IndexViewData) + +type HooksService struct { + indexDataHooks []IndexDataHook +} + +func init() { + registry.RegisterService(&HooksService{}) +} + +func (srv *HooksService) Init() error { + return nil +} + +func (srv *HooksService) AddIndexDataHook(hook IndexDataHook) { + srv.indexDataHooks = append(srv.indexDataHooks, hook) +} + +func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData) { + for _, hook := range srv.indexDataHooks { + hook(indexData) + } +} diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index daaef945b96..afe6269510f 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error } alertNotification := &m.AlertNotification{ - OrgId: cmd.OrgId, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - SendReminder: cmd.SendReminder, - Frequency: frequency, - Created: time.Now(), - Updated: time.Now(), - IsDefault: cmd.IsDefault, + OrgId: cmd.OrgId, + Name: cmd.Name, + Type: cmd.Type, + Settings: cmd.Settings, + SendReminder: cmd.SendReminder, + DisableResolveMessage: cmd.DisableResolveMessage, + Frequency: frequency, + Created: time.Now(), + Updated: time.Now(), + IsDefault: cmd.IsDefault, } if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil { @@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Type = cmd.Type current.IsDefault = cmd.IsDefault current.SendReminder = cmd.SendReminder + current.DisableResolveMessage = cmd.DisableResolveMessage if current.SendReminder { if cmd.Frequency == "" { @@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Frequency = frequency } - sess.UseBool("is_default", "send_reminder") + sess.UseBool("is_default", "send_reminder", "disable_resolve_message") if affected, err := sess.ID(cmd.Id).Update(current); err != nil { return err diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index ed682bae5c6..629a6292eb5 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { So(cmd.Result.OrgId, ShouldNotEqual, 0) So(cmd.Result.Type, ShouldEqual, "email") So(cmd.Result.Frequency, ShouldEqual, 10*time.Second) + So(cmd.Result.DisableResolveMessage, ShouldBeFalse) Convey("Cannot save Alert Notification with the same name", func() { err = CreateAlertNotificationCommand(cmd) @@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) { Convey("Can update alert notification", func() { newCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgId: cmd.Result.OrgId, - SendReminder: true, - Frequency: "60s", - Settings: simplejson.New(), - Id: cmd.Result.Id, + Name: "NewName", + Type: "webhook", + OrgId: cmd.Result.OrgId, + SendReminder: true, + DisableResolveMessage: true, + Frequency: "60s", + Settings: simplejson.New(), + Id: cmd.Result.Id, } err := UpdateAlertNotification(newCmd) So(err, ShouldBeNil) So(newCmd.Result.Name, ShouldEqual, "NewName") So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second) + So(newCmd.Result.DisableResolveMessage, ShouldBeTrue) }) Convey("Can update alert notification to disable sending of reminders", func() { diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index cadcccf6c95..198a47b50ff 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) { mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{ Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0", })) + mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{ + Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0", + })) mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0])) diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index af8500707d5..c02686c24ba 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -182,6 +182,21 @@ func TestAccountDataAccess(t *testing.T) { }) }) + Convey("Removing user from org should delete user completely if in no other org", func() { + // make sure ac2 has no org + err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId}) + So(err, ShouldBeNil) + + // remove frome ac2 from ac1 org + remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true} + err = RemoveOrgUser(&remCmd) + So(err, ShouldBeNil) + So(remCmd.UserWasDeleted, ShouldBeTrue) + + err = GetSignedInUser(&m.GetSignedInUserQuery{UserId: ac2.Id}) + So(err, ShouldEqual, m.ErrUserNotFound) + }) + Convey("Cannot delete last admin org user", func() { cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id} err := RemoveOrgUser(&cmd) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 14981cfde64..abbc320020e 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -157,6 +157,12 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { } } + // validate that after delete there is at least one user with admin role in org + if err := validateOneAdminLeftInOrg(cmd.OrgId, sess); err != nil { + return err + } + + // check user other orgs and update user current org var userOrgs []*m.UserOrgDTO sess.Table("org_user") sess.Join("INNER", "org", "org_user.org_id=org.id") @@ -168,22 +174,31 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { return err } - hasCurrentOrgSet := false - for _, userOrg := range userOrgs { - if user.OrgId == userOrg.OrgId { - hasCurrentOrgSet = true - break + if len(userOrgs) > 0 { + hasCurrentOrgSet := false + for _, userOrg := range userOrgs { + if user.OrgId == userOrg.OrgId { + hasCurrentOrgSet = true + break + } } - } - if !hasCurrentOrgSet && len(userOrgs) > 0 { - err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId) - if err != nil { + if !hasCurrentOrgSet { + err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId) + if err != nil { + return err + } + } + } else if cmd.ShouldDeleteOrphanedUser { + // no other orgs, delete the full user + if err := deleteUserInTransaction(sess, &m.DeleteUserCommand{UserId: user.Id}); err != nil { return err } + + cmd.UserWasDeleted = true } - return validateOneAdminLeftInOrg(cmd.OrgId, sess) + return nil }) } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 5477bc7b2d1..67f1bd7f75a 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -233,7 +233,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) { case migrator.SQLITE: // special case for tests if !filepath.IsAbs(ss.dbCfg.Path) { - ss.dbCfg.Path = filepath.Join(setting.DataPath, ss.dbCfg.Path) + ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path) } os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm) cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc" diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 848a11d81ab..72d5654a777 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -445,27 +445,31 @@ func SearchUsers(query *m.SearchUsersQuery) error { func DeleteUser(cmd *m.DeleteUserCommand) error { return inTransaction(func(sess *DBSession) error { - deletes := []string{ - "DELETE FROM star WHERE user_id = ?", - "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?", - "DELETE FROM org_user WHERE user_id = ?", - "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 { - _, err := sess.Exec(sql, cmd.UserId) - if err != nil { - return err - } - } - - return nil + return deleteUserInTransaction(sess, cmd) }) } +func deleteUserInTransaction(sess *DBSession, cmd *m.DeleteUserCommand) error { + deletes := []string{ + "DELETE FROM star WHERE user_id = ?", + "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?", + "DELETE FROM org_user WHERE user_id = ?", + "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 { + _, err := sess.Exec(sql, cmd.UserId) + if err != nil { + return err + } + } + + return nil +} + func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error { return inTransaction(func(sess *DBSession) error { user := m.User{} diff --git a/pkg/services/sqlstore/user_auth_test.go b/pkg/services/sqlstore/user_auth_test.go index 5ad93dc7a3b..a0dd714fe6f 100644 --- a/pkg/services/sqlstore/user_auth_test.go +++ b/pkg/services/sqlstore/user_auth_test.go @@ -16,7 +16,6 @@ func TestUserAuth(t *testing.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"), @@ -25,7 +24,6 @@ func TestUserAuth(t *testing.T) { } err = CreateUser(context.Background(), cmd) So(err, ShouldBeNil) - users = append(users, cmd.Result) } Reset(func() { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 27df73a9eed..58901e55c6b 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -54,14 +54,11 @@ var ( ApplicationName string // Paths - LogsPath string HomePath string - DataPath string PluginsPath string CustomInitPath = "conf/custom.ini" // Log settings. - LogModes []string LogConfigs []util.DynMap // Http server options @@ -187,11 +184,18 @@ var ( ImageUploadProvider string ) +// TODO move all global vars to this struct type Cfg struct { Raw *ini.File + // HTTP Server Settings + AppUrl string + AppSubUrl string + // Paths ProvisioningPath string + DataPath string + LogsPath string // SMTP email settings Smtp SmtpSettings @@ -411,7 +415,7 @@ func loadSpecifedConfigFile(configFile string, masterFile *ini.File) error { return nil } -func loadConfiguration(args *CommandLineArgs) (*ini.File, error) { +func (cfg *Cfg) loadConfiguration(args *CommandLineArgs) (*ini.File, error) { var err error // load config defaults @@ -442,7 +446,7 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) { // load specified config file err = loadSpecifedConfigFile(args.Config, parsedFile) if err != nil { - initLogging(parsedFile) + cfg.initLogging(parsedFile) log.Fatal(3, err.Error()) } @@ -459,8 +463,8 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) { evalConfigValues(parsedFile) // update data path and logging config - DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath) - initLogging(parsedFile) + cfg.DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath) + cfg.initLogging(parsedFile) return parsedFile, err } @@ -517,7 +521,7 @@ func NewCfg() *Cfg { func (cfg *Cfg) Load(args *CommandLineArgs) error { setHomePath(args) - iniFile, err := loadConfiguration(args) + iniFile, err := cfg.loadConfiguration(args) if err != nil { return err } @@ -538,6 +542,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath) server := iniFile.Section("server") AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server) + cfg.AppUrl = AppUrl + cfg.AppSubUrl = AppSubUrl Protocol = HTTP if server.Key("protocol").MustString("http") == "https" { @@ -662,7 +668,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err) } } - cfg.ImagesDir = filepath.Join(DataPath, "png") + cfg.ImagesDir = filepath.Join(cfg.DataPath, "png") cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) @@ -720,7 +726,7 @@ func (cfg *Cfg) readSessionConfig() { SessionOptions.IDLength = 16 if SessionOptions.Provider == "file" { - SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, DataPath) + SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, cfg.DataPath) os.MkdirAll(path.Dir(SessionOptions.ProviderConfig), os.ModePerm) } @@ -731,15 +737,15 @@ func (cfg *Cfg) readSessionConfig() { SessionConnMaxLifetime = cfg.Raw.Section("session").Key("conn_max_lifetime").MustInt64(14400) } -func initLogging(file *ini.File) { +func (cfg *Cfg) initLogging(file *ini.File) { // split on comma - LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), ",") + logModes := strings.Split(file.Section("log").Key("mode").MustString("console"), ",") // also try space - if len(LogModes) == 1 { - LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ") + if len(logModes) == 1 { + logModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ") } - LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath) - log.ReadLoggingConfig(LogModes, LogsPath, file) + cfg.LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath) + log.ReadLoggingConfig(logModes, cfg.LogsPath, file) } func (cfg *Cfg) LogConfigSources() { @@ -763,8 +769,8 @@ func (cfg *Cfg) LogConfigSources() { } logger.Info("Path Home", "path", HomePath) - logger.Info("Path Data", "path", DataPath) - logger.Info("Path Logs", "path", LogsPath) + logger.Info("Path Data", "path", cfg.DataPath) + logger.Info("Path Logs", "path", cfg.LogsPath) logger.Info("Path Plugins", "path", PluginsPath) logger.Info("Path Provisioning", "path", cfg.ProvisioningPath) logger.Info("App mode " + Env) diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 6524073e4da..72dbe2378c7 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -30,8 +30,8 @@ func TestLoadingSettings(t *testing.T) { cfg.Load(&CommandLineArgs{HomePath: "../../"}) So(AdminUser, ShouldEqual, "superduper") - So(DataPath, ShouldEqual, filepath.Join(HomePath, "data")) - So(LogsPath, ShouldEqual, filepath.Join(DataPath, "log")) + So(cfg.DataPath, ShouldEqual, filepath.Join(HomePath, "data")) + So(cfg.LogsPath, ShouldEqual, filepath.Join(cfg.DataPath, "log")) }) Convey("Should replace password when defined in environment", func() { @@ -76,8 +76,8 @@ func TestLoadingSettings(t *testing.T) { HomePath: "../../", Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`}, }) - So(DataPath, ShouldEqual, `c:\tmp\data`) - So(LogsPath, ShouldEqual, `c:\tmp\logs`) + So(cfg.DataPath, ShouldEqual, `c:\tmp\data`) + So(cfg.LogsPath, ShouldEqual, `c:\tmp\logs`) } else { cfg := NewCfg() cfg.Load(&CommandLineArgs{ @@ -85,8 +85,8 @@ func TestLoadingSettings(t *testing.T) { Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"}, }) - So(DataPath, ShouldEqual, "/tmp/data") - So(LogsPath, ShouldEqual, "/tmp/logs") + So(cfg.DataPath, ShouldEqual, "/tmp/data") + So(cfg.LogsPath, ShouldEqual, "/tmp/logs") } }) @@ -112,7 +112,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{`cfg:default.paths.data=c:\tmp\data`}, }) - So(DataPath, ShouldEqual, `c:\tmp\override`) + So(cfg.DataPath, ShouldEqual, `c:\tmp\override`) } else { cfg := NewCfg() cfg.Load(&CommandLineArgs{ @@ -121,7 +121,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{"cfg:default.paths.data=/tmp/data"}, }) - So(DataPath, ShouldEqual, "/tmp/override") + So(cfg.DataPath, ShouldEqual, "/tmp/override") } }) @@ -134,7 +134,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{`cfg:paths.data=c:\tmp\data`}, }) - So(DataPath, ShouldEqual, `c:\tmp\data`) + So(cfg.DataPath, ShouldEqual, `c:\tmp\data`) } else { cfg := NewCfg() cfg.Load(&CommandLineArgs{ @@ -143,7 +143,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{"cfg:paths.data=/tmp/data"}, }) - So(DataPath, ShouldEqual, "/tmp/data") + So(cfg.DataPath, ShouldEqual, "/tmp/data") } }) @@ -156,7 +156,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, }) - So(DataPath, ShouldEqual, `c:\tmp\env_override`) + So(cfg.DataPath, ShouldEqual, `c:\tmp\env_override`) } else { os.Setenv("GF_DATA_PATH", "/tmp/env_override") cfg := NewCfg() @@ -165,7 +165,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, }) - So(DataPath, ShouldEqual, "/tmp/env_override") + So(cfg.DataPath, ShouldEqual, "/tmp/env_override") } }) diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 72e57d03fa0..469d6baa5de 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -52,13 +52,18 @@ func generateConnectionString(datasource *models.DataSource) string { } server, port := hostParts[0], hostParts[1] - return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", + encrypt := datasource.JsonData.Get("encrypt").MustString("false") + connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", server, port, datasource.Database, datasource.User, password, ) + if encrypt != "false" { + connStr += fmt.Sprintf("encrypt=%s;", encrypt) + } + return connStr } type mssqlRowTransformer struct { diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 8e48994c7ea..c3d4470603d 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -692,7 +692,7 @@ func TestMSSQL(t *testing.T) { }, } - resp, err := endpoint.Query(nil, nil, query) + resp, err := endpoint.Query(context.Background(), nil, query) So(err, ShouldBeNil) queryResult := resp.Results["A"] So(queryResult.Error, ShouldBeNil) diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 7f12b8636bb..476e3ba6586 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -769,7 +769,7 @@ func TestMySQL(t *testing.T) { }, } - resp, err := endpoint.Query(nil, nil, query) + resp, err := endpoint.Query(context.Background(), nil, query) So(err, ShouldBeNil) queryResult := resp.Results["A"] So(queryResult.Error, ShouldBeNil) diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index c0c04522dba..c381938aead 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -701,7 +701,7 @@ func TestPostgres(t *testing.T) { }, } - resp, err := endpoint.Query(nil, nil, query) + resp, err := endpoint.Query(context.Background(), nil, query) So(err, ShouldBeNil) queryResult := resp.Results["A"] So(queryResult.Error, ShouldBeNil) diff --git a/public/app/app.ts b/public/app/app.ts index 8e30747072e..298bf5609cd 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -29,7 +29,11 @@ _.move = (array, fromIndex, toIndex) => { import { coreModule, registerAngularDirectives } from './core/core'; import { setupAngularRoutes } from './routes/routes'; -declare var System: any; +// import symlinked extensions +const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); +extensionsIndex.keys().forEach(key => { + extensionsIndex(key); +}); export class GrafanaApp { registerFunctions: any; @@ -119,7 +123,7 @@ export class GrafanaApp { coreModule.config(setupAngularRoutes); registerAngularDirectives(); - const preBootRequires = [System.import('app/features/all')]; + const preBootRequires = [import('app/features/all')]; Promise.all(preBootRequires) .then(() => { diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx new file mode 100644 index 00000000000..dcb67dde220 --- /dev/null +++ b/public/app/core/components/PageLoader/PageLoader.tsx @@ -0,0 +1,17 @@ +import React, { SFC } from 'react'; + +interface Props { + pageName: string; +} + +const PageLoader: SFC = ({ pageName }) => { + const loadingText = `Loading ${pageName}...`; + return ( +
+ +
{loadingText}
+
+ ); +}; + +export default PageLoader; diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index fc062ce63e4..a60a7dd4af6 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -50,11 +50,11 @@ class AddPermissions extends Component { }; onUserSelected = (user: User) => { - this.setState({ userId: user ? user.id : 0 }); + this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 }); }; onTeamSelected = (team: Team) => { - this.setState({ teamId: team ? team.id : 0 }); + this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 }); }; onPermissionChanged = (permission: OptionWithDescription) => { @@ -82,7 +82,6 @@ class AddPermissions extends Component { const newItem = this.state; const pickerClassName = 'width-20'; const isValid = this.isValid(); - return (
diff --git a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx index d648d06e414..ff679f67ae2 100644 --- a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx +++ b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx @@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component { {}} - value={item.permission} disabled={true} - className={'gf-form-input--form-dropdown-right'} + className={'gf-form-select-box__control--menu-right'} + value={item.permission} /> diff --git a/public/app/core/components/PermissionList/PermissionListItem.tsx b/public/app/core/components/PermissionList/PermissionListItem.tsx index b846f98a063..56b6114d236 100644 --- a/public/app/core/components/PermissionList/PermissionListItem.tsx +++ b/public/app/core/components/PermissionList/PermissionListItem.tsx @@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent { diff --git a/public/app/core/components/Picker/DescriptionOption.tsx b/public/app/core/components/Picker/DescriptionOption.tsx index 1bcb7100489..9ddf13f7532 100644 --- a/public/app/core/components/Picker/DescriptionOption.tsx +++ b/public/app/core/components/Picker/DescriptionOption.tsx @@ -1,56 +1,25 @@ -import React, { Component } from 'react'; +import React from 'react'; +import { components } from 'react-select'; +import { OptionProps } from 'react-select/lib/components/Option'; -export interface Props { - onSelect: any; - onFocus: any; - option: any; - isFocused: any; - className: any; +// https://github.com/JedWatson/react-select/issues/3038 +interface ExtendedOptionProps extends OptionProps { + data: any; } -class DescriptionOption extends Component { - constructor(props) { - super(props); - this.handleMouseDown = this.handleMouseDown.bind(this); - this.handleMouseEnter = this.handleMouseEnter.bind(this); - this.handleMouseMove = this.handleMouseMove.bind(this); - } - - handleMouseDown(event) { - event.preventDefault(); - event.stopPropagation(); - this.props.onSelect(this.props.option, event); - } - - handleMouseEnter(event) { - this.props.onFocus(this.props.option, event); - } - - handleMouseMove(event) { - if (this.props.isFocused) { - return; - } - this.props.onFocus(this.props.option, event); - } - - render() { - const { option, children, className } = this.props; - return ( -