mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
commit
76fc48e2eb
@ -4,6 +4,7 @@ init_cmds = [
|
|||||||
["./bin/grafana-server", "cfg:app_mode=development"]
|
["./bin/grafana-server", "cfg:app_mode=development"]
|
||||||
]
|
]
|
||||||
watch_all = true
|
watch_all = true
|
||||||
|
follow_symlinks = true
|
||||||
watch_dirs = [
|
watch_dirs = [
|
||||||
"$WORKDIR/pkg",
|
"$WORKDIR/pkg",
|
||||||
"$WORKDIR/public/views",
|
"$WORKDIR/public/views",
|
||||||
|
@ -126,7 +126,7 @@ jobs:
|
|||||||
|
|
||||||
build-all:
|
build-all:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.1.0
|
- image: grafana/build-container:1.2.0
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -170,10 +170,11 @@ jobs:
|
|||||||
- scripts/*.sh
|
- scripts/*.sh
|
||||||
- scripts/publish
|
- scripts/publish
|
||||||
- scripts/build/release_publisher/release_publisher
|
- scripts/build/release_publisher/release_publisher
|
||||||
|
- scripts/build/publish.sh
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.1.0
|
- image: grafana/build-container:1.2.0
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -232,7 +233,7 @@ jobs:
|
|||||||
|
|
||||||
build-enterprise:
|
build-enterprise:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:v0.1
|
- image: grafana/build-container:1.2.0
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -54,6 +54,7 @@ profile.cov
|
|||||||
/pkg/cmd/grafana-server/grafana-server
|
/pkg/cmd/grafana-server/grafana-server
|
||||||
/pkg/cmd/grafana-server/debug
|
/pkg/cmd/grafana-server/debug
|
||||||
/pkg/extensions
|
/pkg/extensions
|
||||||
|
/public/app/extensions
|
||||||
debug.test
|
debug.test
|
||||||
/examples/*/dist
|
/examples/*/dist
|
||||||
/packaging/**/*.rpm
|
/packaging/**/*.rpm
|
||||||
@ -68,7 +69,6 @@ debug.test
|
|||||||
/vendor/**/*.yml
|
/vendor/**/*.yml
|
||||||
/vendor/**/*_test.go
|
/vendor/**/*_test.go
|
||||||
/vendor/**/.editorconfig
|
/vendor/**/.editorconfig
|
||||||
/vendor/**/appengine*
|
|
||||||
*.orig
|
*.orig
|
||||||
|
|
||||||
/devenv/bulk-dashboards/*.json
|
/devenv/bulk-dashboards/*.json
|
||||||
|
17
CHANGELOG.md
17
CHANGELOG.md
@ -3,15 +3,30 @@
|
|||||||
### New Features
|
### 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)
|
* **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
|
### Minor
|
||||||
|
|
||||||
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
|
* **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
|
### 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)
|
* 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)
|
# 5.3.0 (2018-10-10)
|
||||||
|
|
||||||
* **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495)
|
* **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)
|
* **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)
|
* **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)
|
* **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)
|
* **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)
|
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
|
||||||
|
|
||||||
|
16
Gopkg.lock
generated
16
Gopkg.lock
generated
@ -264,7 +264,7 @@
|
|||||||
branch = "master"
|
branch = "master"
|
||||||
name = "github.com/hashicorp/yamux"
|
name = "github.com/hashicorp/yamux"
|
||||||
packages = ["."]
|
packages = ["."]
|
||||||
revision = "2658be15c5f05e76244154714161f17e3e77de2e"
|
revision = "7221087c3d281fda5f794e28c2ea4c6e4d5c4558"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
name = "github.com/inconshreveable/log15"
|
name = "github.com/inconshreveable/log15"
|
||||||
@ -507,6 +507,8 @@
|
|||||||
branch = "master"
|
branch = "master"
|
||||||
name = "golang.org/x/crypto"
|
name = "golang.org/x/crypto"
|
||||||
packages = [
|
packages = [
|
||||||
|
"ed25519",
|
||||||
|
"ed25519/internal/edwards25519",
|
||||||
"md4",
|
"md4",
|
||||||
"pbkdf2"
|
"pbkdf2"
|
||||||
]
|
]
|
||||||
@ -670,6 +672,16 @@
|
|||||||
revision = "e6179049628164864e6e84e973cfb56335748dea"
|
revision = "e6179049628164864e6e84e973cfb56335748dea"
|
||||||
version = "v2.3.2"
|
version = "v2.3.2"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "gopkg.in/square/go-jose.v2"
|
||||||
|
packages = [
|
||||||
|
".",
|
||||||
|
"cipher",
|
||||||
|
"json"
|
||||||
|
]
|
||||||
|
revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8"
|
||||||
|
version = "v2.1.9"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
name = "gopkg.in/yaml.v2"
|
name = "gopkg.in/yaml.v2"
|
||||||
packages = ["."]
|
packages = ["."]
|
||||||
@ -679,6 +691,6 @@
|
|||||||
[solve-meta]
|
[solve-meta]
|
||||||
analyzer-name = "dep"
|
analyzer-name = "dep"
|
||||||
analyzer-version = 1
|
analyzer-version = 1
|
||||||
inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4"
|
inputs-digest = "6f7f271afd27f78b7d8ebe27436fee72c9925fb82a978bdc57fde44e01f3ca51"
|
||||||
solver-name = "gps-cdcl"
|
solver-name = "gps-cdcl"
|
||||||
solver-version = 1
|
solver-version = 1
|
||||||
|
@ -207,3 +207,7 @@ ignored = [
|
|||||||
[[constraint]]
|
[[constraint]]
|
||||||
name = "github.com/VividCortex/mysqlerr"
|
name = "github.com/VividCortex/mysqlerr"
|
||||||
branch = "master"
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "gopkg.in/square/go-jose.v2"
|
||||||
|
version = "2.1.9"
|
||||||
|
@ -138,5 +138,5 @@ plugin development.
|
|||||||
|
|
||||||
## License
|
## 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).
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ os: Windows Server 2012 R2
|
|||||||
clone_folder: c:\gopath\src\github.com\grafana\grafana
|
clone_folder: c:\gopath\src\github.com\grafana\grafana
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
nodejs_version: "6"
|
nodejs_version: "8"
|
||||||
GOPATH: C:\gopath
|
GOPATH: C:\gopath
|
||||||
GOVERSION: 1.11
|
GOVERSION: 1.11
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
- "8083:8083"
|
- "8083:8083"
|
||||||
- "8086:8086"
|
- "8086:8086"
|
||||||
volumes:
|
volumes:
|
||||||
- ./blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
|
- ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
|
||||||
|
|
||||||
fake-influxdb-data:
|
fake-influxdb-data:
|
||||||
image: grafana/fake-data-gen
|
image: grafana/fake-data-gen
|
||||||
|
@ -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. |
|
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
|
||||||
| graphiteVersion | string | Graphite | Graphite version |
|
| 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 |
|
| 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) |
|
| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
|
||||||
| timeField | string | Elastic | Which field that should be used as timestamp |
|
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
|
||||||
| interval | string | Elastic | Index date time format |
|
| interval | string | Elasticsearch | Index date time format |
|
||||||
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
|
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
|
||||||
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
|
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
|
||||||
| defaultRegion | string | Cloudwatch | AWS region |
|
| 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 |
|
| tsdbVersion | string | OpenTSDB | Version |
|
||||||
| tsdbResolution | string | OpenTSDB | Resolution |
|
| tsdbResolution | string | OpenTSDB | Resolution |
|
||||||
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
|
| 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 |
|
| 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 |
|
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
|
||||||
| maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |
|
| maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |
|
||||||
|
@ -128,7 +128,7 @@ Example json body:
|
|||||||
|
|
||||||
In DingTalk PC Client:
|
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".
|
2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage".
|
||||||
|
|
||||||
|
@ -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`.
|
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:
|
Example config:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -46,6 +46,9 @@ team_ids =
|
|||||||
allowed_organizations =
|
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
|
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
|
on the login page. You can now login or sign up with your GitHub
|
||||||
accounts.
|
accounts.
|
||||||
|
@ -58,6 +58,9 @@ api_url = https://gitlab.com/api/v4
|
|||||||
allowed_groups =
|
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.
|
Restart the Grafana backend for your changes to take effect.
|
||||||
|
|
||||||
If you use your own instance of GitLab instead of `gitlab.com`, adjust
|
If you use your own instance of GitLab instead of `gitlab.com`, adjust
|
||||||
|
@ -45,6 +45,9 @@ allowed_domains = mycompany.com mycompany.org
|
|||||||
allow_sign_up = true
|
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
|
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
|
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.
|
accounts. The `allowed_domains` option is optional, and domains were separated by space.
|
||||||
|
@ -32,6 +32,7 @@ Name | Description
|
|||||||
*Database* | Name of your MSSQL database.
|
*Database* | Name of your MSSQL database.
|
||||||
*User* | Database user's login/username
|
*User* | Database user's login/username
|
||||||
*Password* | Database user's password
|
*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 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 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+).
|
*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
|
### 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.
|
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.
|
||||||
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 possible, we recommend you to use the latest service pack available for optimal compatibility.
|
||||||
|
|
||||||
## Query Editor
|
## Query Editor
|
||||||
|
|
||||||
|
@ -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.
|
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`.
|
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.
|
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.
|
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.
|
> 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:
|
The following APIs need to be enabled first:
|
||||||
|
|
||||||
- [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.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)
|
* [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
|
||||||
|
|
||||||
Click on the links above and click the `Enable` button:
|
Click on the links above and click the `Enable` button:
|
||||||
|
|
||||||

|
{{< 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
|
#### Create a GCP Service Account for a Project
|
||||||
|
|
||||||
1. Navigate to the [APIs & Services Credentials page](https://console.cloud.google.com/apis/credentials).
|
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.
|
2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option.
|
||||||
|
|
||||||

|
{{< 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:
|
3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option:
|
||||||
|
|
||||||

|
{{< 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:
|
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:
|
||||||
|
|
||||||

|
{{< 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.
|
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.
|
6. Upload it to Grafana on the datasource Configuration page. You can either upload the file or paste in the contents of the file.
|
||||||
|
|
||||||

|
{{< 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!
|
7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file!
|
||||||
|
|
||||||

|
{{< 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
|
## 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
|
### Filter
|
||||||
|
|
||||||
@ -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 `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:
|
The option is called `Stackdriver auto` and the defaults are:
|
||||||
|
|
||||||
- 1m for time ranges < 23 hours
|
* 1m for time ranges < 23 hours
|
||||||
- 5m for time ranges >= 23 hours and < 6 days
|
* 5m for time ranges >= 23 hours and < 6 days
|
||||||
- 1h for time ranges >= 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).
|
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:
|
There are two syntaxes:
|
||||||
|
|
||||||
- `$<varname>` Example: rate(http_requests_total{job=~"$job"}[5m])
|
* `$<varname>` Example: `metric.label.$metric_label`
|
||||||
- `[[varname]]` Example: rate(http_requests_total{job=~"[[job]]"}[5m])
|
* `[[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
|
## 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
|
[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
|
## Configure the Datasource with Provisioning
|
||||||
|
|
||||||
@ -173,6 +202,7 @@ apiVersion: 1
|
|||||||
datasources:
|
datasources:
|
||||||
- name: Stackdriver
|
- name: Stackdriver
|
||||||
type: stackdriver
|
type: stackdriver
|
||||||
|
access: proxy
|
||||||
jsonData:
|
jsonData:
|
||||||
tokenUri: https://oauth2.googleapis.com/token
|
tokenUri: https://oauth2.googleapis.com/token
|
||||||
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
|
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
|
||||||
|
@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]
|
|||||||
<h4>Provisioning</h4>
|
<h4>Provisioning</h4>
|
||||||
<p>A guide to help you automate your Grafana setup & configuration.</p>
|
<p>A guide to help you automate your Grafana setup & configuration.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{< relref "guides/whats-new-in-v5-2.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
<a href="{{< relref "guides/whats-new-in-v5-3.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
||||||
<h4>What's new in v5.2</h4>
|
<h4>What's new in v5.3</h4>
|
||||||
<p>Article on all the new cool features and enhancements in v5.2</p>
|
<p>Article on all the new cool features and enhancements in v5.3</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
<a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
||||||
<h4>Screencasts</h4>
|
<h4>Screencasts</h4>
|
||||||
@ -88,9 +88,13 @@ aliases = ["v1.1", "guides/reference/admin"]
|
|||||||
<img src="/img/docs/logos/icon_prometheus.svg" >
|
<img src="/img/docs/logos/icon_prometheus.svg" >
|
||||||
<h5>Prometheus</h5>
|
<h5>Prometheus</h5>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{< relref "features/datasources/opentsdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
<a href="{{< relref "features/datasources/stackdriver.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||||
<img src="/img/docs/logos/icon_opentsdb.png" >
|
<img src="/img/docs/logos/stackdriver_logo.png">
|
||||||
<h5>OpenTSDB</h5>
|
<h5>Google Stackdriver</h5>
|
||||||
|
</a>
|
||||||
|
<a href="{{< relref "features/datasources/cloudwatch.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||||
|
<img src="/img/docs/logos/icon_cloudwatch.svg">
|
||||||
|
<h5>Cloudwatch</h5>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{< relref "features/datasources/mysql.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
<a href="{{< relref "features/datasources/mysql.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||||
<img src="/img/docs/logos/icon_mysql.png" >
|
<img src="/img/docs/logos/icon_mysql.png" >
|
||||||
@ -100,8 +104,12 @@ aliases = ["v1.1", "guides/reference/admin"]
|
|||||||
<img src="/img/docs/logos/icon_postgres.svg" >
|
<img src="/img/docs/logos/icon_postgres.svg" >
|
||||||
<h5>Postgres</h5>
|
<h5>Postgres</h5>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{< relref "features/datasources/cloudwatch.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
<a href="{{< relref "features/datasources/mssql.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||||
<img src="/img/docs/logos/icon_cloudwatch.svg">
|
<img src="/img/docs/logos/sql_server_logo.svg">
|
||||||
<h5>Cloudwatch</h5>
|
<h5>Microsoft SQL Server</h5>
|
||||||
|
</a>
|
||||||
|
<a href="{{< relref "features/datasources/opentsdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||||
|
<img src="/img/docs/logos/icon_opentsdb.png" >
|
||||||
|
<h5>OpenTSDB</h5>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,7 +10,7 @@ weight = 1
|
|||||||
|
|
||||||
# Developer Guide
|
# 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
|
## Short version
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ There are two blog posts about authoring a plugin that might also be of interest
|
|||||||
## What languages?
|
## 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
|
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
|
## 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:
|
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 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" >}}).
|
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" >}}).
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"stable": "5.3.0",
|
"stable": "5.3.1",
|
||||||
"testing": "5.3.0"
|
"testing": "5.3.1"
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"@types/react": "^16.4.14",
|
"@types/react": "^16.4.14",
|
||||||
"@types/react-custom-scrollbars": "^4.0.5",
|
"@types/react-custom-scrollbars": "^4.0.5",
|
||||||
"@types/react-dom": "^16.0.7",
|
"@types/react-dom": "^16.0.7",
|
||||||
|
"@types/react-select": "^2.0.4",
|
||||||
"angular-mocks": "1.6.6",
|
"angular-mocks": "1.6.6",
|
||||||
"autoprefixer": "^6.4.0",
|
"autoprefixer": "^6.4.0",
|
||||||
"axios": "^0.17.1",
|
"axios": "^0.17.1",
|
||||||
@ -79,14 +80,14 @@
|
|||||||
"style-loader": "^0.21.0",
|
"style-loader": "^0.21.0",
|
||||||
"systemjs": "0.20.19",
|
"systemjs": "0.20.19",
|
||||||
"systemjs-plugin-css": "^0.1.36",
|
"systemjs-plugin-css": "^0.1.36",
|
||||||
"ts-jest": "^23.1.4",
|
"ts-jest": "^23.10.4",
|
||||||
"ts-loader": "^5.1.0",
|
"ts-loader": "^5.1.0",
|
||||||
"tslib": "^1.9.3",
|
"tslib": "^1.9.3",
|
||||||
"tslint": "^5.8.0",
|
"tslint": "^5.8.0",
|
||||||
"tslint-loader": "^3.5.3",
|
"tslint-loader": "^3.5.3",
|
||||||
"typescript": "^3.0.3",
|
"typescript": "^3.0.3",
|
||||||
"uglifyjs-webpack-plugin": "^1.2.7",
|
"uglifyjs-webpack-plugin": "^1.2.7",
|
||||||
"webpack": "^4.8.0",
|
"webpack": "4.19.1",
|
||||||
"webpack-bundle-analyzer": "^2.9.0",
|
"webpack-bundle-analyzer": "^2.9.0",
|
||||||
"webpack-cleanup-plugin": "^0.5.1",
|
"webpack-cleanup-plugin": "^0.5.1",
|
||||||
"webpack-cli": "^2.1.4",
|
"webpack-cli": "^2.1.4",
|
||||||
@ -157,7 +158,7 @@
|
|||||||
"react-highlight-words": "^0.10.0",
|
"react-highlight-words": "^0.10.0",
|
||||||
"react-popper": "^0.7.5",
|
"react-popper": "^0.7.5",
|
||||||
"react-redux": "^5.0.7",
|
"react-redux": "^5.0.7",
|
||||||
"react-select": "^1.1.0",
|
"react-select": "2.1.0",
|
||||||
"react-sizeme": "^2.3.6",
|
"react-sizeme": "^2.3.6",
|
||||||
"react-transition-group": "^2.2.1",
|
"react-transition-group": "^2.2.1",
|
||||||
"redux": "^4.0.0",
|
"redux": "^4.0.0",
|
||||||
|
112
pkg/api/api.go
112
pkg/api/api.go
@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (hs *HTTPServer) registerRoutes() {
|
func (hs *HTTPServer) registerRoutes() {
|
||||||
reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
|
reqSignedIn := middleware.ReqSignedIn
|
||||||
reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
|
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||||
reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
|
reqEditorRole := middleware.ReqEditorRole
|
||||||
reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
|
reqOrgAdmin := middleware.ReqOrgAdmin
|
||||||
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
|
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
|
||||||
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
|
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
|
||||||
quota := middleware.Quota
|
quota := middleware.Quota
|
||||||
@ -22,66 +22,66 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r := hs.RouteRegister
|
r := hs.RouteRegister
|
||||||
|
|
||||||
// not logged in views
|
// not logged in views
|
||||||
r.Get("/", reqSignedIn, Index)
|
r.Get("/", reqSignedIn, hs.Index)
|
||||||
r.Get("/logout", Logout)
|
r.Get("/logout", Logout)
|
||||||
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
|
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
|
||||||
r.Get("/login/:name", quota("session"), OAuthLogin)
|
r.Get("/login/:name", quota("session"), OAuthLogin)
|
||||||
r.Get("/login", LoginView)
|
r.Get("/login", hs.LoginView)
|
||||||
r.Get("/invite/:code", Index)
|
r.Get("/invite/:code", hs.Index)
|
||||||
|
|
||||||
// authed views
|
// authed views
|
||||||
r.Get("/profile/", reqSignedIn, Index)
|
r.Get("/profile/", reqSignedIn, hs.Index)
|
||||||
r.Get("/profile/password", reqSignedIn, Index)
|
r.Get("/profile/password", reqSignedIn, hs.Index)
|
||||||
r.Get("/profile/switch-org/:id", reqSignedIn, ChangeActiveOrgAndRedirectToHome)
|
r.Get("/profile/switch-org/:id", reqSignedIn, hs.ChangeActiveOrgAndRedirectToHome)
|
||||||
r.Get("/org/", reqSignedIn, Index)
|
r.Get("/org/", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/new", reqSignedIn, Index)
|
r.Get("/org/new", reqSignedIn, hs.Index)
|
||||||
r.Get("/datasources/", reqSignedIn, Index)
|
r.Get("/datasources/", reqSignedIn, hs.Index)
|
||||||
r.Get("/datasources/new", reqSignedIn, Index)
|
r.Get("/datasources/new", reqSignedIn, hs.Index)
|
||||||
r.Get("/datasources/edit/*", reqSignedIn, Index)
|
r.Get("/datasources/edit/*", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/users", reqSignedIn, Index)
|
r.Get("/org/users", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/users/new", reqSignedIn, Index)
|
r.Get("/org/users/new", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/users/invite", reqSignedIn, Index)
|
r.Get("/org/users/invite", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/teams", reqSignedIn, Index)
|
r.Get("/org/teams", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/teams/*", reqSignedIn, Index)
|
r.Get("/org/teams/*", reqSignedIn, hs.Index)
|
||||||
r.Get("/org/apikeys/", reqSignedIn, Index)
|
r.Get("/org/apikeys/", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboard/import/", reqSignedIn, Index)
|
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
|
||||||
r.Get("/configuration", reqGrafanaAdmin, Index)
|
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin", reqGrafanaAdmin, Index)
|
r.Get("/admin", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/settings", reqGrafanaAdmin, Index)
|
r.Get("/admin/settings", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/users", reqGrafanaAdmin, Index)
|
r.Get("/admin/users", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/users/create", reqGrafanaAdmin, Index)
|
r.Get("/admin/users/create", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index)
|
r.Get("/admin/users/edit/:id", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/orgs", reqGrafanaAdmin, Index)
|
r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
|
r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin/stats", reqGrafanaAdmin, 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", reqSignedIn, hs.Index)
|
||||||
r.Get("/plugins/:id/edit", reqSignedIn, Index)
|
r.Get("/plugins/:id/edit", reqSignedIn, hs.Index)
|
||||||
r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
|
r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index)
|
||||||
|
|
||||||
r.Get("/d/:uid/:slug", reqSignedIn, Index)
|
r.Get("/d/:uid/:slug", reqSignedIn, hs.Index)
|
||||||
r.Get("/d/:uid", reqSignedIn, Index)
|
r.Get("/d/:uid", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, Index)
|
r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, hs.Index)
|
||||||
r.Get("/dashboard/script/*", reqSignedIn, Index)
|
r.Get("/dashboard/script/*", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboard-solo/snapshot/*", Index)
|
r.Get("/dashboard-solo/snapshot/*", hs.Index)
|
||||||
r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
|
r.Get("/d-solo/:uid/:slug", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, Index)
|
r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, hs.Index)
|
||||||
r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
|
r.Get("/dashboard-solo/script/*", reqSignedIn, hs.Index)
|
||||||
r.Get("/import/dashboard", reqSignedIn, Index)
|
r.Get("/import/dashboard", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboards/", reqSignedIn, Index)
|
r.Get("/dashboards/", reqSignedIn, hs.Index)
|
||||||
r.Get("/dashboards/*", reqSignedIn, 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, hs.Index)
|
||||||
r.Get("/playlists/*", reqSignedIn, Index)
|
r.Get("/playlists/*", reqSignedIn, hs.Index)
|
||||||
r.Get("/alerting/", reqSignedIn, Index)
|
r.Get("/alerting/", reqSignedIn, hs.Index)
|
||||||
r.Get("/alerting/*", reqSignedIn, Index)
|
r.Get("/alerting/*", reqSignedIn, hs.Index)
|
||||||
|
|
||||||
// sign up
|
// sign up
|
||||||
r.Get("/signup", Index)
|
r.Get("/signup", hs.Index)
|
||||||
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
|
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", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
|
||||||
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
|
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))
|
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
|
||||||
|
|
||||||
// reset password
|
// reset password
|
||||||
r.Get("/user/password/send-reset-email", Index)
|
r.Get("/user/password/send-reset-email", hs.Index)
|
||||||
r.Get("/user/password/reset", 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/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail))
|
||||||
r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword))
|
r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword))
|
||||||
|
|
||||||
// dashboard snapshots
|
// dashboard snapshots
|
||||||
r.Get("/dashboard/snapshot/*", Index)
|
r.Get("/dashboard/snapshot/*", hs.Index)
|
||||||
r.Get("/dashboard/snapshots/", reqSignedIn, Index)
|
r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index)
|
||||||
|
|
||||||
// api for dashboard snapshots
|
// api for dashboard snapshots
|
||||||
r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
|
r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/alerting"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"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)
|
return Error(403, err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == m.ErrDashboardContainsInvalidAlertData {
|
if validationErr, ok := err.(alerting.ValidationError); ok {
|
||||||
return Error(500, "Invalid alert data. Cannot save dashboard", err)
|
return Error(422, validationErr.Error(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/alerting"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
|
||||||
@ -725,7 +726,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
|||||||
{SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412},
|
{SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412},
|
||||||
{SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400},
|
{SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400},
|
||||||
{SaveError: m.ErrDashboardFolderCannotHaveParent, 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.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500},
|
||||||
{SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500},
|
{SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500},
|
||||||
{SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400},
|
{SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400},
|
||||||
|
@ -57,6 +57,7 @@ func NewAlertNotification(notification *models.AlertNotification) *AlertNotifica
|
|||||||
Updated: notification.Updated,
|
Updated: notification.Updated,
|
||||||
Frequency: formatShort(notification.Frequency),
|
Frequency: formatShort(notification.Frequency),
|
||||||
SendReminder: notification.SendReminder,
|
SendReminder: notification.SendReminder,
|
||||||
|
DisableResolveMessage: notification.DisableResolveMessage,
|
||||||
Settings: notification.Settings,
|
Settings: notification.Settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,6 +68,7 @@ type AlertNotification struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
SendReminder bool `json:"sendReminder"`
|
SendReminder bool `json:"sendReminder"`
|
||||||
|
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||||
Frequency string `json:"frequency"`
|
Frequency string `json:"frequency"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
@ -103,6 +105,7 @@ type NotificationTestCommand struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SendReminder bool `json:"sendReminder"`
|
SendReminder bool `json:"sendReminder"`
|
||||||
|
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||||
Frequency string `json:"frequency"`
|
Frequency string `json:"frequency"`
|
||||||
Settings *simplejson.Json `json:"settings"`
|
Settings *simplejson.Json `json:"settings"`
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"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/services/rendering"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
@ -52,6 +53,7 @@ type HTTPServer struct {
|
|||||||
Bus bus.Bus `inject:""`
|
Bus bus.Bus `inject:""`
|
||||||
RenderService rendering.Service `inject:""`
|
RenderService rendering.Service `inject:""`
|
||||||
Cfg *setting.Cfg `inject:""`
|
Cfg *setting.Cfg `inject:""`
|
||||||
|
HooksService *hooks.HooksService `inject:""`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) Init() error {
|
func (hs *HTTPServer) Init() error {
|
||||||
@ -184,7 +186,7 @@ func (hs *HTTPServer) applyRoutes() {
|
|||||||
// then custom app proxy routes
|
// then custom app proxy routes
|
||||||
hs.initAppPluginRoutes(hs.macaron)
|
hs.initAppPluginRoutes(hs.macaron)
|
||||||
// lastly not found route
|
// lastly not found route
|
||||||
hs.macaron.NotFound(NotFoundHandler)
|
hs.macaron.NotFound(hs.NotFoundHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
||||||
|
@ -17,7 +17,7 @@ const (
|
|||||||
darkName = "dark"
|
darkName = "dark"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
||||||
settings, err := getFrontendSettingsMap(c)
|
settings, err := getFrontendSettingsMap(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -350,11 +350,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hs.HooksService.RunIndexDataHooks(&data)
|
||||||
return &data, nil
|
return &data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Index(c *m.ReqContext) {
|
func (hs *HTTPServer) Index(c *m.ReqContext) {
|
||||||
data, err := setIndexViewData(c)
|
data, err := hs.setIndexViewData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Handle(500, "Failed to get settings", err)
|
c.Handle(500, "Failed to get settings", err)
|
||||||
return
|
return
|
||||||
@ -362,13 +363,13 @@ func Index(c *m.ReqContext) {
|
|||||||
c.HTML(200, "index", data)
|
c.HTML(200, "index", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NotFoundHandler(c *m.ReqContext) {
|
func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) {
|
||||||
if c.IsApiRequest() {
|
if c.IsApiRequest() {
|
||||||
c.JsonApiErr(404, "Not found", nil)
|
c.JsonApiErr(404, "Not found", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := setIndexViewData(c)
|
data, err := hs.setIndexViewData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Handle(500, "Failed to get settings", err)
|
c.Handle(500, "Failed to get settings", err)
|
||||||
return
|
return
|
||||||
|
@ -17,8 +17,8 @@ const (
|
|||||||
ViewIndex = "index"
|
ViewIndex = "index"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoginView(c *m.ReqContext) {
|
func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
||||||
viewData, err := setIndexViewData(c)
|
viewData, err := hs.setIndexViewData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Handle(500, "Failed to get settings", err)
|
c.Handle(500, "Failed to get settings", err)
|
||||||
return
|
return
|
||||||
|
@ -45,7 +45,7 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
|
|||||||
|
|
||||||
// GET /api/org/users
|
// GET /api/org/users
|
||||||
func GetOrgUsersForCurrentOrg(c *m.ReqContext) Response {
|
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
|
// GET /api/orgs/:orgId/users
|
||||||
@ -102,26 +102,32 @@ func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response {
|
|||||||
|
|
||||||
// DELETE /api/org/users/:userId
|
// DELETE /api/org/users/:userId
|
||||||
func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response {
|
func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response {
|
||||||
userID := c.ParamsInt64(":userId")
|
return removeOrgUserHelper(&m.RemoveOrgUserCommand{
|
||||||
return removeOrgUserHelper(c.OrgId, userID)
|
UserId: c.ParamsInt64(":userId"),
|
||||||
|
OrgId: c.OrgId,
|
||||||
|
ShouldDeleteOrphanedUser: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/orgs/:orgId/users/:userId
|
// DELETE /api/orgs/:orgId/users/:userId
|
||||||
func RemoveOrgUser(c *m.ReqContext) Response {
|
func RemoveOrgUser(c *m.ReqContext) Response {
|
||||||
userID := c.ParamsInt64(":userId")
|
return removeOrgUserHelper(&m.RemoveOrgUserCommand{
|
||||||
orgID := c.ParamsInt64(":orgId")
|
UserId: c.ParamsInt64(":userId"),
|
||||||
return removeOrgUserHelper(orgID, userID)
|
OrgId: c.ParamsInt64(":orgId"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeOrgUserHelper(orgID int64, userID int64) Response {
|
func removeOrgUserHelper(cmd *m.RemoveOrgUserCommand) Response {
|
||||||
cmd := m.RemoveOrgUserCommand{OrgId: orgID, UserId: userID}
|
if err := bus.Dispatch(cmd); err != nil {
|
||||||
|
|
||||||
if err := bus.Dispatch(&cmd); err != nil {
|
|
||||||
if err == m.ErrLastOrgAdmin {
|
if err == m.ErrLastOrgAdmin {
|
||||||
return Error(400, "Cannot remove last organization admin", nil)
|
return Error(400, "Cannot remove last organization admin", nil)
|
||||||
}
|
}
|
||||||
return Error(500, "Failed to remove user from organization", err)
|
return Error(500, "Failed to remove user from organization", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.UserWasDeleted {
|
||||||
|
return Success("User deleted")
|
||||||
|
}
|
||||||
|
|
||||||
return Success("User removed from organization")
|
return Success("User removed from organization")
|
||||||
}
|
}
|
||||||
|
@ -177,17 +177,17 @@ func UserSetUsingOrg(c *m.ReqContext) Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /profile/switch-org/:id
|
// GET /profile/switch-org/:id
|
||||||
func ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
|
func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
|
||||||
orgID := c.ParamsInt64(":id")
|
orgID := c.ParamsInt64(":id")
|
||||||
|
|
||||||
if !validateUsingOrg(c.UserId, orgID) {
|
if !validateUsingOrg(c.UserId, orgID) {
|
||||||
NotFoundHandler(c)
|
hs.NotFoundHandler(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID}
|
cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID}
|
||||||
|
|
||||||
if err := bus.Dispatch(&cmd); err != nil {
|
if err := bus.Dispatch(&cmd); err != nil {
|
||||||
NotFoundHandler(c)
|
hs.NotFoundHandler(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(setting.AppSubUrl + "/")
|
c.Redirect(setting.AppSubUrl + "/")
|
||||||
|
@ -100,7 +100,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) {
|
|||||||
sighupChan := make(chan os.Signal, 1)
|
sighupChan := make(chan os.Signal, 1)
|
||||||
|
|
||||||
signal.Notify(sighupChan, syscall.SIGHUP)
|
signal.Notify(sighupChan, syscall.SIGHUP)
|
||||||
signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
|
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
package extensions
|
package extensions
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "gopkg.in/square/go-jose.v2"
|
||||||
|
)
|
||||||
|
|
||||||
var IsEnterprise bool = false
|
var IsEnterprise bool = false
|
||||||
|
@ -14,6 +14,13 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/util"
|
"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 {
|
func GetContextHandler() macaron.Handler {
|
||||||
return func(c *macaron.Context) {
|
return func(c *macaron.Context) {
|
||||||
ctx := &m.ReqContext{
|
ctx := &m.ReqContext{
|
||||||
|
@ -28,6 +28,7 @@ type AlertNotification struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SendReminder bool `json:"sendReminder"`
|
SendReminder bool `json:"sendReminder"`
|
||||||
|
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||||
Frequency time.Duration `json:"frequency"`
|
Frequency time.Duration `json:"frequency"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
Settings *simplejson.Json `json:"settings"`
|
Settings *simplejson.Json `json:"settings"`
|
||||||
@ -39,6 +40,7 @@ type CreateAlertNotificationCommand struct {
|
|||||||
Name string `json:"name" binding:"Required"`
|
Name string `json:"name" binding:"Required"`
|
||||||
Type string `json:"type" binding:"Required"`
|
Type string `json:"type" binding:"Required"`
|
||||||
SendReminder bool `json:"sendReminder"`
|
SendReminder bool `json:"sendReminder"`
|
||||||
|
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||||
Frequency string `json:"frequency"`
|
Frequency string `json:"frequency"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
Settings *simplejson.Json `json:"settings"`
|
Settings *simplejson.Json `json:"settings"`
|
||||||
@ -52,6 +54,7 @@ type UpdateAlertNotificationCommand struct {
|
|||||||
Name string `json:"name" binding:"Required"`
|
Name string `json:"name" binding:"Required"`
|
||||||
Type string `json:"type" binding:"Required"`
|
Type string `json:"type" binding:"Required"`
|
||||||
SendReminder bool `json:"sendReminder"`
|
SendReminder bool `json:"sendReminder"`
|
||||||
|
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||||
Frequency string `json:"frequency"`
|
Frequency string `json:"frequency"`
|
||||||
IsDefault bool `json:"isDefault"`
|
IsDefault bool `json:"isDefault"`
|
||||||
Settings *simplejson.Json `json:"settings" binding:"Required"`
|
Settings *simplejson.Json `json:"settings" binding:"Required"`
|
||||||
|
@ -21,7 +21,6 @@ var (
|
|||||||
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
|
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
|
||||||
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
|
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
|
||||||
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
|
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")
|
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
|
||||||
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
|
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
|
||||||
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
|
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
|
||||||
|
@ -74,6 +74,8 @@ type OrgUser struct {
|
|||||||
type RemoveOrgUserCommand struct {
|
type RemoveOrgUserCommand struct {
|
||||||
UserId int64
|
UserId int64
|
||||||
OrgId int64
|
OrgId int64
|
||||||
|
ShouldDeleteOrphanedUser bool
|
||||||
|
UserWasDeleted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddOrgUserCommand struct {
|
type AddOrgUserCommand struct {
|
||||||
|
@ -2,6 +2,7 @@ package conditions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/null"
|
"github.com/grafana/grafana/pkg/components/null"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
@ -31,12 +32,12 @@ type ThresholdEvaluator struct {
|
|||||||
func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) {
|
func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) {
|
||||||
params := model.Get("params").MustArray()
|
params := model.Get("params").MustArray()
|
||||||
if len(params) == 0 {
|
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)
|
firstParam, ok := params[0].(json.Number)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"}
|
return nil, fmt.Errorf("Evaluator has invalid parameter")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultEval := &ThresholdEvaluator{Type: typ}
|
defaultEval := &ThresholdEvaluator{Type: typ}
|
||||||
@ -107,7 +108,7 @@ func (e *RangedEvaluator) Eval(reducedValue null.Float) bool {
|
|||||||
func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
||||||
typ := model.Get("type").MustString()
|
typ := model.Get("type").MustString()
|
||||||
if typ == "" {
|
if typ == "" {
|
||||||
return nil, alerting.ValidationError{Reason: "Evaluator missing type property"}
|
return nil, fmt.Errorf("Evaluator missing type property")
|
||||||
}
|
}
|
||||||
|
|
||||||
if inSlice(typ, defaultTypes) {
|
if inSlice(typ, defaultTypes) {
|
||||||
@ -122,7 +123,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
|||||||
return &NoValueEvaluator{}, nil
|
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 {
|
func inSlice(a string, list []string) bool {
|
||||||
|
@ -82,8 +82,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
|||||||
if collapsed && collapsedJSON.MustBool() {
|
if collapsed && collapsedJSON.MustBool() {
|
||||||
|
|
||||||
// extract alerts from sub panels for collapsed panels
|
// extract alerts from sub panels for collapsed panels
|
||||||
alertSlice, err := e.getAlertFromPanels(panel,
|
alertSlice, err := e.getAlertFromPanels(panel, validateAlertFunc)
|
||||||
validateAlertFunc)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -100,7 +99,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
|||||||
|
|
||||||
panelID, err := panel.Get("id").Int64()
|
panelID, err := panel.Get("id").Int64()
|
||||||
if err != nil {
|
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
|
// backward compatibility check, can be removed later
|
||||||
@ -146,7 +145,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
|||||||
|
|
||||||
datasource, err := e.lookupDatasourceID(dsName)
|
datasource, err := e.lookupDatasourceID(dsName)
|
||||||
if err != nil {
|
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)
|
jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
|
||||||
@ -167,8 +167,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !validateAlertFunc(alert) {
|
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, ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelId)}
|
||||||
return nil, m.ErrDashboardContainsInvalidAlertData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
alerts = append(alerts, alert)
|
alerts = append(alerts, alert)
|
||||||
|
@ -258,7 +258,7 @@ func TestAlertRuleExtraction(t *testing.T) {
|
|||||||
|
|
||||||
Convey("Should fail on save", func() {
|
Convey("Should fail on save", func() {
|
||||||
_, err := extractor.GetAlerts()
|
_, err := extractor.GetAlerts()
|
||||||
So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData)
|
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -27,6 +27,7 @@ type Notifier interface {
|
|||||||
GetNotifierId() int64
|
GetNotifierId() int64
|
||||||
GetIsDefault() bool
|
GetIsDefault() bool
|
||||||
GetSendReminder() bool
|
GetSendReminder() bool
|
||||||
|
GetDisableResolveMessage() bool
|
||||||
GetFrequency() time.Duration
|
GetFrequency() time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/alerting"
|
"github.com/grafana/grafana/pkg/services/alerting"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,6 +20,7 @@ type NotifierBase struct {
|
|||||||
IsDeault bool
|
IsDeault bool
|
||||||
UploadImage bool
|
UploadImage bool
|
||||||
SendReminder bool
|
SendReminder bool
|
||||||
|
DisableResolveMessage bool
|
||||||
Frequency time.Duration
|
Frequency time.Duration
|
||||||
|
|
||||||
log log.Logger
|
log log.Logger
|
||||||
@ -40,6 +40,7 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
|
|||||||
Type: model.Type,
|
Type: model.Type,
|
||||||
UploadImage: uploadImage,
|
UploadImage: uploadImage,
|
||||||
SendReminder: model.SendReminder,
|
SendReminder: model.SendReminder,
|
||||||
|
DisableResolveMessage: model.DisableResolveMessage,
|
||||||
Frequency: model.Frequency,
|
Frequency: model.Frequency,
|
||||||
log: log.New("alerting.notifier." + model.Name),
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool {
|
|||||||
return n.SendReminder
|
return n.SendReminder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NotifierBase) GetDisableResolveMessage() bool {
|
||||||
|
return n.DisableResolveMessage
|
||||||
|
}
|
||||||
|
|
||||||
func (n *NotifierBase) GetFrequency() time.Duration {
|
func (n *NotifierBase) GetFrequency() time.Duration {
|
||||||
return n.Frequency
|
return n.Frequency
|
||||||
}
|
}
|
||||||
|
@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) {
|
|||||||
base := NewNotifierBase(model)
|
base := NewNotifierBase(model)
|
||||||
So(base.UploadImage, ShouldBeTrue)
|
So(base.UploadImage, ShouldBeTrue)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Convey("default value should be false for backwards compatibility", func() {
|
||||||
|
base := NewNotifierBase(model)
|
||||||
|
So(base.DisableResolveMessage, ShouldBeFalse)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -127,7 +127,13 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -36,13 +36,13 @@ type ValidationError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e ValidationError) Error() string {
|
func (e ValidationError) Error() string {
|
||||||
extraInfo := ""
|
extraInfo := e.Reason
|
||||||
if e.Alertid != 0 {
|
if e.Alertid != 0 {
|
||||||
extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
|
extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.PanelId != 0 {
|
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 {
|
if e.DashboardId != 0 {
|
||||||
@ -50,10 +50,10 @@ func (e ValidationError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if e.Err != nil {
|
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 (
|
var (
|
||||||
@ -128,7 +128,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(model.Conditions) == 0 {
|
if len(model.Conditions) == 0 {
|
||||||
return nil, fmt.Errorf("Alert is missing conditions")
|
return nil, ValidationError{Reason: "Alert is missing conditions"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model, nil
|
return model, nil
|
||||||
|
@ -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 {
|
func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool {
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/guardian"
|
"github.com/grafana/grafana/pkg/services/guardian"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -25,7 +26,9 @@ type DashboardProvisioningService interface {
|
|||||||
|
|
||||||
// NewService factory for creating a new dashboard service
|
// NewService factory for creating a new dashboard service
|
||||||
var NewService = func() DashboardService {
|
var NewService = func() DashboardService {
|
||||||
return &dashboardServiceImpl{}
|
return &dashboardServiceImpl{
|
||||||
|
log: log.New("dashboard-service"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvisioningService factory for creating a new dashboard provisioning service
|
// NewProvisioningService factory for creating a new dashboard provisioning service
|
||||||
@ -45,6 +48,7 @@ type SaveDashboardDTO struct {
|
|||||||
type dashboardServiceImpl struct {
|
type dashboardServiceImpl struct {
|
||||||
orgId int64
|
orgId int64
|
||||||
user *models.SignedInUser
|
user *models.SignedInUser
|
||||||
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
|
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 {
|
if err := bus.Dispatch(&validateAlertsCmd); err != nil {
|
||||||
return nil, models.ErrDashboardContainsInvalidAlertData
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,12 +117,12 @@ func TestDashboardService(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||||
return errors.New("error")
|
return errors.New("Alert validation error")
|
||||||
})
|
})
|
||||||
|
|
||||||
dto.Dashboard = models.NewDashboard("Dash")
|
dto.Dashboard = models.NewDashboard("Dash")
|
||||||
_, err := service.SaveDashboard(dto)
|
_, err := service.SaveDashboard(dto)
|
||||||
So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData)
|
So(err.Error(), ShouldEqual, "Alert validation error")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
30
pkg/services/hooks/hooks.go
Normal file
30
pkg/services/hooks/hooks.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
|
|||||||
alert_notification.updated,
|
alert_notification.updated,
|
||||||
alert_notification.settings,
|
alert_notification.settings,
|
||||||
alert_notification.is_default,
|
alert_notification.is_default,
|
||||||
|
alert_notification.disable_resolve_message,
|
||||||
alert_notification.send_reminder,
|
alert_notification.send_reminder,
|
||||||
alert_notification.frequency
|
alert_notification.frequency
|
||||||
FROM alert_notification
|
FROM alert_notification
|
||||||
@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
|
|||||||
alert_notification.updated,
|
alert_notification.updated,
|
||||||
alert_notification.settings,
|
alert_notification.settings,
|
||||||
alert_notification.is_default,
|
alert_notification.is_default,
|
||||||
|
alert_notification.disable_resolve_message,
|
||||||
alert_notification.send_reminder,
|
alert_notification.send_reminder,
|
||||||
alert_notification.frequency
|
alert_notification.frequency
|
||||||
FROM alert_notification
|
FROM alert_notification
|
||||||
@ -171,6 +173,7 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
|
|||||||
Type: cmd.Type,
|
Type: cmd.Type,
|
||||||
Settings: cmd.Settings,
|
Settings: cmd.Settings,
|
||||||
SendReminder: cmd.SendReminder,
|
SendReminder: cmd.SendReminder,
|
||||||
|
DisableResolveMessage: cmd.DisableResolveMessage,
|
||||||
Frequency: frequency,
|
Frequency: frequency,
|
||||||
Created: time.Now(),
|
Created: time.Now(),
|
||||||
Updated: time.Now(),
|
Updated: time.Now(),
|
||||||
@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
|
|||||||
current.Type = cmd.Type
|
current.Type = cmd.Type
|
||||||
current.IsDefault = cmd.IsDefault
|
current.IsDefault = cmd.IsDefault
|
||||||
current.SendReminder = cmd.SendReminder
|
current.SendReminder = cmd.SendReminder
|
||||||
|
current.DisableResolveMessage = cmd.DisableResolveMessage
|
||||||
|
|
||||||
if current.SendReminder {
|
if current.SendReminder {
|
||||||
if cmd.Frequency == "" {
|
if cmd.Frequency == "" {
|
||||||
@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
|
|||||||
current.Frequency = frequency
|
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 {
|
if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
|||||||
So(cmd.Result.OrgId, ShouldNotEqual, 0)
|
So(cmd.Result.OrgId, ShouldNotEqual, 0)
|
||||||
So(cmd.Result.Type, ShouldEqual, "email")
|
So(cmd.Result.Type, ShouldEqual, "email")
|
||||||
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
|
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
|
||||||
|
So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
|
||||||
|
|
||||||
Convey("Cannot save Alert Notification with the same name", func() {
|
Convey("Cannot save Alert Notification with the same name", func() {
|
||||||
err = CreateAlertNotificationCommand(cmd)
|
err = CreateAlertNotificationCommand(cmd)
|
||||||
@ -231,6 +232,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
|||||||
Type: "webhook",
|
Type: "webhook",
|
||||||
OrgId: cmd.Result.OrgId,
|
OrgId: cmd.Result.OrgId,
|
||||||
SendReminder: true,
|
SendReminder: true,
|
||||||
|
DisableResolveMessage: true,
|
||||||
Frequency: "60s",
|
Frequency: "60s",
|
||||||
Settings: simplejson.New(),
|
Settings: simplejson.New(),
|
||||||
Id: cmd.Result.Id,
|
Id: cmd.Result.Id,
|
||||||
@ -239,6 +241,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
|||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(newCmd.Result.Name, ShouldEqual, "NewName")
|
So(newCmd.Result.Name, ShouldEqual, "NewName")
|
||||||
So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
|
So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
|
||||||
|
So(newCmd.Result.DisableResolveMessage, ShouldBeTrue)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("Can update alert notification to disable sending of reminders", func() {
|
Convey("Can update alert notification to disable sending of reminders", func() {
|
||||||
|
@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) {
|
|||||||
mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
|
mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
|
||||||
Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0",
|
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]))
|
mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0]))
|
||||||
|
|
||||||
|
@ -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() {
|
Convey("Cannot delete last admin org user", func() {
|
||||||
cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
|
cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
|
||||||
err := RemoveOrgUser(&cmd)
|
err := RemoveOrgUser(&cmd)
|
||||||
|
@ -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
|
var userOrgs []*m.UserOrgDTO
|
||||||
sess.Table("org_user")
|
sess.Table("org_user")
|
||||||
sess.Join("INNER", "org", "org_user.org_id=org.id")
|
sess.Join("INNER", "org", "org_user.org_id=org.id")
|
||||||
@ -168,6 +174,7 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(userOrgs) > 0 {
|
||||||
hasCurrentOrgSet := false
|
hasCurrentOrgSet := false
|
||||||
for _, userOrg := range userOrgs {
|
for _, userOrg := range userOrgs {
|
||||||
if user.OrgId == userOrg.OrgId {
|
if user.OrgId == userOrg.OrgId {
|
||||||
@ -176,14 +183,22 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasCurrentOrgSet && len(userOrgs) > 0 {
|
if !hasCurrentOrgSet {
|
||||||
err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
|
err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
return validateOneAdminLeftInOrg(cmd.OrgId, sess)
|
cmd.UserWasDeleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,7 +233,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
|
|||||||
case migrator.SQLITE:
|
case migrator.SQLITE:
|
||||||
// special case for tests
|
// special case for tests
|
||||||
if !filepath.IsAbs(ss.dbCfg.Path) {
|
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)
|
os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
|
||||||
cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
|
cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
|
||||||
|
@ -445,6 +445,11 @@ func SearchUsers(query *m.SearchUsersQuery) error {
|
|||||||
|
|
||||||
func DeleteUser(cmd *m.DeleteUserCommand) error {
|
func DeleteUser(cmd *m.DeleteUserCommand) error {
|
||||||
return inTransaction(func(sess *DBSession) error {
|
return inTransaction(func(sess *DBSession) error {
|
||||||
|
return deleteUserInTransaction(sess, cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserInTransaction(sess *DBSession, cmd *m.DeleteUserCommand) error {
|
||||||
deletes := []string{
|
deletes := []string{
|
||||||
"DELETE FROM star WHERE user_id = ?",
|
"DELETE FROM star WHERE user_id = ?",
|
||||||
"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
|
"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
|
||||||
@ -463,7 +468,6 @@ func DeleteUser(cmd *m.DeleteUserCommand) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error {
|
func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error {
|
||||||
|
@ -16,7 +16,6 @@ func TestUserAuth(t *testing.T) {
|
|||||||
Convey("Given 5 users", t, func() {
|
Convey("Given 5 users", t, func() {
|
||||||
var err error
|
var err error
|
||||||
var cmd *m.CreateUserCommand
|
var cmd *m.CreateUserCommand
|
||||||
users := []m.User{}
|
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
cmd = &m.CreateUserCommand{
|
cmd = &m.CreateUserCommand{
|
||||||
Email: fmt.Sprint("user", i, "@test.com"),
|
Email: fmt.Sprint("user", i, "@test.com"),
|
||||||
@ -25,7 +24,6 @@ func TestUserAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
err = CreateUser(context.Background(), cmd)
|
err = CreateUser(context.Background(), cmd)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
users = append(users, cmd.Result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Reset(func() {
|
Reset(func() {
|
||||||
|
@ -54,14 +54,11 @@ var (
|
|||||||
ApplicationName string
|
ApplicationName string
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
LogsPath string
|
|
||||||
HomePath string
|
HomePath string
|
||||||
DataPath string
|
|
||||||
PluginsPath string
|
PluginsPath string
|
||||||
CustomInitPath = "conf/custom.ini"
|
CustomInitPath = "conf/custom.ini"
|
||||||
|
|
||||||
// Log settings.
|
// Log settings.
|
||||||
LogModes []string
|
|
||||||
LogConfigs []util.DynMap
|
LogConfigs []util.DynMap
|
||||||
|
|
||||||
// Http server options
|
// Http server options
|
||||||
@ -187,11 +184,18 @@ var (
|
|||||||
ImageUploadProvider string
|
ImageUploadProvider string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO move all global vars to this struct
|
||||||
type Cfg struct {
|
type Cfg struct {
|
||||||
Raw *ini.File
|
Raw *ini.File
|
||||||
|
|
||||||
|
// HTTP Server Settings
|
||||||
|
AppUrl string
|
||||||
|
AppSubUrl string
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
ProvisioningPath string
|
ProvisioningPath string
|
||||||
|
DataPath string
|
||||||
|
LogsPath string
|
||||||
|
|
||||||
// SMTP email settings
|
// SMTP email settings
|
||||||
Smtp SmtpSettings
|
Smtp SmtpSettings
|
||||||
@ -411,7 +415,7 @@ func loadSpecifedConfigFile(configFile string, masterFile *ini.File) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
|
func (cfg *Cfg) loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// load config defaults
|
// load config defaults
|
||||||
@ -442,7 +446,7 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
|
|||||||
// load specified config file
|
// load specified config file
|
||||||
err = loadSpecifedConfigFile(args.Config, parsedFile)
|
err = loadSpecifedConfigFile(args.Config, parsedFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
initLogging(parsedFile)
|
cfg.initLogging(parsedFile)
|
||||||
log.Fatal(3, err.Error())
|
log.Fatal(3, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,8 +463,8 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
|
|||||||
evalConfigValues(parsedFile)
|
evalConfigValues(parsedFile)
|
||||||
|
|
||||||
// update data path and logging config
|
// update data path and logging config
|
||||||
DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
|
cfg.DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
|
||||||
initLogging(parsedFile)
|
cfg.initLogging(parsedFile)
|
||||||
|
|
||||||
return parsedFile, err
|
return parsedFile, err
|
||||||
}
|
}
|
||||||
@ -517,7 +521,7 @@ func NewCfg() *Cfg {
|
|||||||
func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||||
setHomePath(args)
|
setHomePath(args)
|
||||||
|
|
||||||
iniFile, err := loadConfiguration(args)
|
iniFile, err := cfg.loadConfiguration(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -538,6 +542,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
|
cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
|
||||||
server := iniFile.Section("server")
|
server := iniFile.Section("server")
|
||||||
AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
|
AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
|
||||||
|
cfg.AppUrl = AppUrl
|
||||||
|
cfg.AppSubUrl = AppSubUrl
|
||||||
|
|
||||||
Protocol = HTTP
|
Protocol = HTTP
|
||||||
if server.Key("protocol").MustString("http") == "https" {
|
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)
|
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.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
|
||||||
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
|
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
|
||||||
cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true)
|
cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true)
|
||||||
@ -720,7 +726,7 @@ func (cfg *Cfg) readSessionConfig() {
|
|||||||
SessionOptions.IDLength = 16
|
SessionOptions.IDLength = 16
|
||||||
|
|
||||||
if SessionOptions.Provider == "file" {
|
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)
|
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)
|
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
|
// 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
|
// also try space
|
||||||
if len(LogModes) == 1 {
|
if len(logModes) == 1 {
|
||||||
LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
|
logModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
|
||||||
}
|
}
|
||||||
LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
|
cfg.LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
|
||||||
log.ReadLoggingConfig(LogModes, LogsPath, file)
|
log.ReadLoggingConfig(logModes, cfg.LogsPath, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Cfg) LogConfigSources() {
|
func (cfg *Cfg) LogConfigSources() {
|
||||||
@ -763,8 +769,8 @@ func (cfg *Cfg) LogConfigSources() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Path Home", "path", HomePath)
|
logger.Info("Path Home", "path", HomePath)
|
||||||
logger.Info("Path Data", "path", DataPath)
|
logger.Info("Path Data", "path", cfg.DataPath)
|
||||||
logger.Info("Path Logs", "path", LogsPath)
|
logger.Info("Path Logs", "path", cfg.LogsPath)
|
||||||
logger.Info("Path Plugins", "path", PluginsPath)
|
logger.Info("Path Plugins", "path", PluginsPath)
|
||||||
logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
|
logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
|
||||||
logger.Info("App mode " + Env)
|
logger.Info("App mode " + Env)
|
||||||
|
@ -30,8 +30,8 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
cfg.Load(&CommandLineArgs{HomePath: "../../"})
|
cfg.Load(&CommandLineArgs{HomePath: "../../"})
|
||||||
|
|
||||||
So(AdminUser, ShouldEqual, "superduper")
|
So(AdminUser, ShouldEqual, "superduper")
|
||||||
So(DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
|
So(cfg.DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
|
||||||
So(LogsPath, ShouldEqual, filepath.Join(DataPath, "log"))
|
So(cfg.LogsPath, ShouldEqual, filepath.Join(cfg.DataPath, "log"))
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("Should replace password when defined in environment", func() {
|
Convey("Should replace password when defined in environment", func() {
|
||||||
@ -76,8 +76,8 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
HomePath: "../../",
|
HomePath: "../../",
|
||||||
Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
|
Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
|
||||||
})
|
})
|
||||||
So(DataPath, ShouldEqual, `c:\tmp\data`)
|
So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
|
||||||
So(LogsPath, ShouldEqual, `c:\tmp\logs`)
|
So(cfg.LogsPath, ShouldEqual, `c:\tmp\logs`)
|
||||||
} else {
|
} else {
|
||||||
cfg := NewCfg()
|
cfg := NewCfg()
|
||||||
cfg.Load(&CommandLineArgs{
|
cfg.Load(&CommandLineArgs{
|
||||||
@ -85,8 +85,8 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
|
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
|
||||||
})
|
})
|
||||||
|
|
||||||
So(DataPath, ShouldEqual, "/tmp/data")
|
So(cfg.DataPath, ShouldEqual, "/tmp/data")
|
||||||
So(LogsPath, ShouldEqual, "/tmp/logs")
|
So(cfg.LogsPath, ShouldEqual, "/tmp/logs")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
Args: []string{`cfg:default.paths.data=c:\tmp\data`},
|
Args: []string{`cfg:default.paths.data=c:\tmp\data`},
|
||||||
})
|
})
|
||||||
|
|
||||||
So(DataPath, ShouldEqual, `c:\tmp\override`)
|
So(cfg.DataPath, ShouldEqual, `c:\tmp\override`)
|
||||||
} else {
|
} else {
|
||||||
cfg := NewCfg()
|
cfg := NewCfg()
|
||||||
cfg.Load(&CommandLineArgs{
|
cfg.Load(&CommandLineArgs{
|
||||||
@ -121,7 +121,7 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
Args: []string{"cfg:default.paths.data=/tmp/data"},
|
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`},
|
Args: []string{`cfg:paths.data=c:\tmp\data`},
|
||||||
})
|
})
|
||||||
|
|
||||||
So(DataPath, ShouldEqual, `c:\tmp\data`)
|
So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
|
||||||
} else {
|
} else {
|
||||||
cfg := NewCfg()
|
cfg := NewCfg()
|
||||||
cfg.Load(&CommandLineArgs{
|
cfg.Load(&CommandLineArgs{
|
||||||
@ -143,7 +143,7 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
Args: []string{"cfg:paths.data=/tmp/data"},
|
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}"},
|
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
||||||
})
|
})
|
||||||
|
|
||||||
So(DataPath, ShouldEqual, `c:\tmp\env_override`)
|
So(cfg.DataPath, ShouldEqual, `c:\tmp\env_override`)
|
||||||
} else {
|
} else {
|
||||||
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
|
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
|
||||||
cfg := NewCfg()
|
cfg := NewCfg()
|
||||||
@ -165,7 +165,7 @@ func TestLoadingSettings(t *testing.T) {
|
|||||||
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
||||||
})
|
})
|
||||||
|
|
||||||
So(DataPath, ShouldEqual, "/tmp/env_override")
|
So(cfg.DataPath, ShouldEqual, "/tmp/env_override")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -52,13 +52,18 @@ func generateConnectionString(datasource *models.DataSource) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server, port := hostParts[0], hostParts[1]
|
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,
|
server,
|
||||||
port,
|
port,
|
||||||
datasource.Database,
|
datasource.Database,
|
||||||
datasource.User,
|
datasource.User,
|
||||||
password,
|
password,
|
||||||
)
|
)
|
||||||
|
if encrypt != "false" {
|
||||||
|
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
|
||||||
|
}
|
||||||
|
return connStr
|
||||||
}
|
}
|
||||||
|
|
||||||
type mssqlRowTransformer struct {
|
type mssqlRowTransformer struct {
|
||||||
|
@ -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)
|
So(err, ShouldBeNil)
|
||||||
queryResult := resp.Results["A"]
|
queryResult := resp.Results["A"]
|
||||||
So(queryResult.Error, ShouldBeNil)
|
So(queryResult.Error, ShouldBeNil)
|
||||||
|
@ -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)
|
So(err, ShouldBeNil)
|
||||||
queryResult := resp.Results["A"]
|
queryResult := resp.Results["A"]
|
||||||
So(queryResult.Error, ShouldBeNil)
|
So(queryResult.Error, ShouldBeNil)
|
||||||
|
@ -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)
|
So(err, ShouldBeNil)
|
||||||
queryResult := resp.Results["A"]
|
queryResult := resp.Results["A"]
|
||||||
So(queryResult.Error, ShouldBeNil)
|
So(queryResult.Error, ShouldBeNil)
|
||||||
|
@ -29,7 +29,11 @@ _.move = (array, fromIndex, toIndex) => {
|
|||||||
import { coreModule, registerAngularDirectives } from './core/core';
|
import { coreModule, registerAngularDirectives } from './core/core';
|
||||||
import { setupAngularRoutes } from './routes/routes';
|
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 {
|
export class GrafanaApp {
|
||||||
registerFunctions: any;
|
registerFunctions: any;
|
||||||
@ -119,7 +123,7 @@ export class GrafanaApp {
|
|||||||
coreModule.config(setupAngularRoutes);
|
coreModule.config(setupAngularRoutes);
|
||||||
registerAngularDirectives();
|
registerAngularDirectives();
|
||||||
|
|
||||||
const preBootRequires = [System.import('app/features/all')];
|
const preBootRequires = [import('app/features/all')];
|
||||||
|
|
||||||
Promise.all(preBootRequires)
|
Promise.all(preBootRequires)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React, { SFC } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageLoader: SFC<Props> = ({ pageName }) => {
|
||||||
|
const loadingText = `Loading ${pageName}...`;
|
||||||
|
return (
|
||||||
|
<div className="page-loader-wrapper">
|
||||||
|
<i className="page-loader-wrapper__spinner fa fa-spinner fa-spin" />
|
||||||
|
<div className="page-loader-wrapper__text">{loadingText}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageLoader;
|
@ -50,11 +50,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onUserSelected = (user: User) => {
|
onUserSelected = (user: User) => {
|
||||||
this.setState({ userId: user ? user.id : 0 });
|
this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
onTeamSelected = (team: Team) => {
|
onTeamSelected = (team: Team) => {
|
||||||
this.setState({ teamId: team ? team.id : 0 });
|
this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||||
@ -82,7 +82,6 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
|||||||
const newItem = this.state;
|
const newItem = this.state;
|
||||||
const pickerClassName = 'width-20';
|
const pickerClassName = 'width-20';
|
||||||
const isValid = this.isValid();
|
const isValid = this.isValid();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gf-form-inline cta-form">
|
<div className="gf-form-inline cta-form">
|
||||||
<button className="cta-form__close btn btn-transparent" onClick={onCancel}>
|
<button className="cta-form__close btn btn-transparent" onClick={onCancel}>
|
||||||
@ -107,21 +106,13 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
|||||||
|
|
||||||
{newItem.type === AclTarget.User ? (
|
{newItem.type === AclTarget.User ? (
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<UserPicker
|
<UserPicker onSelected={this.onUserSelected} className={pickerClassName} />
|
||||||
onSelected={this.onUserSelected}
|
|
||||||
value={newItem.userId.toString()}
|
|
||||||
className={pickerClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{newItem.type === AclTarget.Team ? (
|
{newItem.type === AclTarget.Team ? (
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<TeamPicker
|
<TeamPicker onSelected={this.onTeamSelected} className={pickerClassName} />
|
||||||
onSelected={this.onTeamSelected}
|
|
||||||
value={newItem.teamId.toString()}
|
|
||||||
className={pickerClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -129,9 +120,8 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
|||||||
<DescriptionPicker
|
<DescriptionPicker
|
||||||
optionsWithDesc={dashboardPermissionLevels}
|
optionsWithDesc={dashboardPermissionLevels}
|
||||||
onSelected={this.onPermissionChanged}
|
onSelected={this.onPermissionChanged}
|
||||||
value={newItem.permission}
|
|
||||||
disabled={false}
|
disabled={false}
|
||||||
className={'gf-form-input--form-dropdown-right'}
|
className={'gf-form-select-box__control--menu-right'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
|
|||||||
<DescriptionPicker
|
<DescriptionPicker
|
||||||
optionsWithDesc={dashboardPermissionLevels}
|
optionsWithDesc={dashboardPermissionLevels}
|
||||||
onSelected={() => {}}
|
onSelected={() => {}}
|
||||||
value={item.permission}
|
|
||||||
disabled={true}
|
disabled={true}
|
||||||
className={'gf-form-input--form-dropdown-right'}
|
className={'gf-form-select-box__control--menu-right'}
|
||||||
|
value={item.permission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent<Props> {
|
|||||||
<DescriptionPicker
|
<DescriptionPicker
|
||||||
optionsWithDesc={dashboardPermissionLevels}
|
optionsWithDesc={dashboardPermissionLevels}
|
||||||
onSelected={this.onPermissionChanged}
|
onSelected={this.onPermissionChanged}
|
||||||
value={item.permission}
|
|
||||||
disabled={item.inherited}
|
disabled={item.inherited}
|
||||||
className={'gf-form-input--form-dropdown-right'}
|
className={'gf-form-select-box__control--menu-right'}
|
||||||
|
value={item.permission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -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 {
|
// https://github.com/JedWatson/react-select/issues/3038
|
||||||
onSelect: any;
|
interface ExtendedOptionProps extends OptionProps<any> {
|
||||||
onFocus: any;
|
data: any;
|
||||||
option: any;
|
|
||||||
isFocused: any;
|
|
||||||
className: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DescriptionOption extends Component<Props, any> {
|
export const Option = (props: ExtendedOptionProps) => {
|
||||||
constructor(props) {
|
const { children, isSelected, data, className } = 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 (
|
return (
|
||||||
<button
|
<components.Option {...props}>
|
||||||
onMouseDown={this.handleMouseDown}
|
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||||
onMouseEnter={this.handleMouseEnter}
|
{isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
|
||||||
onMouseMove={this.handleMouseMove}
|
|
||||||
title={option.title}
|
|
||||||
className={`description-picker-option__button btn btn-link ${className} width-19`}
|
|
||||||
>
|
|
||||||
<div className="gf-form">{children}</div>
|
<div className="gf-form">{children}</div>
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<div className="muted width-17">{option.description}</div>
|
<div className="muted width-17">{data.description}</div>
|
||||||
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
</components.Option>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default DescriptionOption;
|
export default Option;
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import DescriptionOption from './DescriptionOption';
|
import DescriptionOption from './DescriptionOption';
|
||||||
|
import IndicatorsContainer from './IndicatorsContainer';
|
||||||
export interface Props {
|
import ResetStyles from './ResetStyles';
|
||||||
optionsWithDesc: OptionWithDescription[];
|
import NoOptionsMessage from './NoOptionsMessage';
|
||||||
onSelected: (permission) => void;
|
|
||||||
value: number;
|
|
||||||
disabled: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionWithDescription {
|
export interface OptionWithDescription {
|
||||||
value: any;
|
value: any;
|
||||||
@ -16,29 +11,42 @@ export interface OptionWithDescription {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
optionsWithDesc: OptionWithDescription[];
|
||||||
|
onSelected: (permission) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
className?: string;
|
||||||
|
value?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
|
||||||
|
|
||||||
class DescriptionPicker extends Component<Props, any> {
|
class DescriptionPicker extends Component<Props, any> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
|
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
|
||||||
|
const selectedOption = getSelectedOption(optionsWithDesc, value);
|
||||||
return (
|
return (
|
||||||
<div className="permissions-picker">
|
<div className="permissions-picker">
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
|
||||||
valueKey="value"
|
|
||||||
multi={false}
|
|
||||||
clearable={false}
|
|
||||||
labelKey="label"
|
|
||||||
options={optionsWithDesc}
|
|
||||||
onChange={onSelected}
|
|
||||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
|
||||||
optionComponent={DescriptionOption}
|
|
||||||
placeholder="Choose"
|
placeholder="Choose"
|
||||||
disabled={disabled}
|
classNamePrefix={`gf-form-select-box`}
|
||||||
|
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||||
|
options={optionsWithDesc}
|
||||||
|
components={{
|
||||||
|
Option: DescriptionOption,
|
||||||
|
IndicatorsContainer,
|
||||||
|
NoOptionsMessage,
|
||||||
|
}}
|
||||||
|
styles={ResetStyles}
|
||||||
|
isDisabled={disabled}
|
||||||
|
onChange={onSelected}
|
||||||
|
getOptionValue={i => i.value}
|
||||||
|
getOptionLabel={i => i.label}
|
||||||
|
value={selectedOption}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
|
||||||
|
export const IndicatorsContainer = props => {
|
||||||
|
const isOpen = props.selectProps.menuIsOpen;
|
||||||
|
return (
|
||||||
|
<components.IndicatorsContainer {...props}>
|
||||||
|
<span
|
||||||
|
className={`gf-form-select-box__select-arrow ${isOpen ? `gf-form-select-box__select-arrow--reversed` : ''}`}
|
||||||
|
/>
|
||||||
|
</components.IndicatorsContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndicatorsContainer;
|
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
import { OptionProps } from 'react-select/lib/components/Option';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
children: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PickerOption = (props: OptionProps<any>) => {
|
||||||
|
const { children, className } = props;
|
||||||
|
return (
|
||||||
|
<components.Option {...props}>
|
||||||
|
<div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
|
||||||
|
</components.Option>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PickerOption;
|
@ -3,10 +3,26 @@ import renderer from 'react-test-renderer';
|
|||||||
import PickerOption from './PickerOption';
|
import PickerOption from './PickerOption';
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
onSelect: () => {},
|
cx: jest.fn(),
|
||||||
onFocus: () => {},
|
clearValue: jest.fn(),
|
||||||
isFocused: () => {},
|
onSelect: jest.fn(),
|
||||||
option: {
|
getStyles: jest.fn(),
|
||||||
|
getValue: jest.fn(),
|
||||||
|
hasValue: true,
|
||||||
|
isMulti: false,
|
||||||
|
options: [],
|
||||||
|
selectOption: jest.fn(),
|
||||||
|
selectProps: {},
|
||||||
|
setValue: jest.fn(),
|
||||||
|
isDisabled: false,
|
||||||
|
isFocused: false,
|
||||||
|
isSelected: false,
|
||||||
|
innerRef: null,
|
||||||
|
innerProps: null,
|
||||||
|
label: 'Option label',
|
||||||
|
type: null,
|
||||||
|
children: 'Model title',
|
||||||
|
data: {
|
||||||
title: 'Model title',
|
title: 'Model title',
|
||||||
avatarUrl: 'url/to/avatar',
|
avatarUrl: 'url/to/avatar',
|
||||||
label: 'User picker label',
|
label: 'User picker label',
|
||||||
|
@ -1,54 +1,22 @@
|
|||||||
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 {
|
// https://github.com/JedWatson/react-select/issues/3038
|
||||||
onSelect: any;
|
interface ExtendedOptionProps extends OptionProps<any> {
|
||||||
onFocus: any;
|
data: any;
|
||||||
option: any;
|
|
||||||
isFocused: any;
|
|
||||||
className: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserPickerOption extends Component<Props, any> {
|
export const PickerOption = (props: ExtendedOptionProps) => {
|
||||||
constructor(props) {
|
const { children, data, className } = 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 (
|
return (
|
||||||
<button
|
<components.Option {...props}>
|
||||||
onMouseDown={this.handleMouseDown}
|
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||||
onMouseEnter={this.handleMouseEnter}
|
{data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
|
||||||
onMouseMove={this.handleMouseMove}
|
|
||||||
title={option.title}
|
|
||||||
className={`user-picker-option__button btn btn-link ${className}`}
|
|
||||||
>
|
|
||||||
<img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
|
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</div>
|
||||||
|
</components.Option>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default UserPickerOption;
|
export default PickerOption;
|
||||||
|
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export default {
|
||||||
|
clearIndicator: () => ({}),
|
||||||
|
container: () => ({}),
|
||||||
|
control: () => ({}),
|
||||||
|
dropdownIndicator: () => ({}),
|
||||||
|
group: () => ({}),
|
||||||
|
groupHeading: () => ({}),
|
||||||
|
indicatorsContainer: () => ({}),
|
||||||
|
indicatorSeparator: () => ({}),
|
||||||
|
input: () => ({}),
|
||||||
|
loadingIndicator: () => ({}),
|
||||||
|
loadingMessage: () => ({}),
|
||||||
|
menu: () => ({}),
|
||||||
|
menuList: () => ({}),
|
||||||
|
multiValue: () => ({}),
|
||||||
|
multiValueLabel: () => ({}),
|
||||||
|
multiValueRemove: () => ({}),
|
||||||
|
noOptionsMessage: () => ({}),
|
||||||
|
option: () => ({}),
|
||||||
|
placeholder: () => ({}),
|
||||||
|
singleValue: () => ({}),
|
||||||
|
valueContainer: () => ({}),
|
||||||
|
};
|
@ -1,18 +1,11 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import Select from 'react-select';
|
import AsyncSelect from 'react-select/lib/Async';
|
||||||
import PickerOption from './PickerOption';
|
import PickerOption from './PickerOption';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import ResetStyles from './ResetStyles';
|
||||||
export interface Props {
|
import IndicatorsContainer from './IndicatorsContainer';
|
||||||
onSelected: (team: Team) => void;
|
import NoOptionsMessage from './NoOptionsMessage';
|
||||||
value?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface State {
|
|
||||||
isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: number;
|
id: number;
|
||||||
@ -21,6 +14,15 @@ export interface Team {
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onSelected: (team: Team) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamPicker extends Component<Props, State> {
|
export class TeamPicker extends Component<Props, State> {
|
||||||
debouncedSearch: any;
|
debouncedSearch: any;
|
||||||
|
|
||||||
@ -31,7 +33,7 @@ export class TeamPicker extends Component<Props, State> {
|
|||||||
|
|
||||||
this.debouncedSearch = debounce(this.search, 300, {
|
this.debouncedSearch = debounce(this.search, 300, {
|
||||||
leading: true,
|
leading: true,
|
||||||
trailing: false,
|
trailing: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +41,7 @@ export class TeamPicker extends Component<Props, State> {
|
|||||||
const backendSrv = getBackendSrv();
|
const backendSrv = getBackendSrv();
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
|
|
||||||
return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => {
|
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||||
const teams = result.teams.map(team => {
|
const teams = result.teams.map(team => {
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
@ -50,31 +52,34 @@ export class TeamPicker extends Component<Props, State> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.setState({ isLoading: false });
|
this.setState({ isLoading: false });
|
||||||
return { options: teams };
|
return teams;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { onSelected, value, className } = this.props;
|
const { onSelected, className } = this.props;
|
||||||
const { isLoading } = this.state;
|
const { isLoading } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-picker">
|
<div className="user-picker">
|
||||||
<Select.Async
|
<AsyncSelect
|
||||||
valueKey="id"
|
classNamePrefix={`gf-form-select-box`}
|
||||||
multi={false}
|
isMulti={false}
|
||||||
labelKey="label"
|
|
||||||
cache={false}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
defaultOptions={true}
|
||||||
loadOptions={this.debouncedSearch}
|
loadOptions={this.debouncedSearch}
|
||||||
loadingPlaceholder="Loading..."
|
|
||||||
noResultsText="No teams found"
|
|
||||||
onChange={onSelected}
|
onChange={onSelected}
|
||||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||||
optionComponent={PickerOption}
|
styles={ResetStyles}
|
||||||
|
components={{
|
||||||
|
Option: PickerOption,
|
||||||
|
IndicatorsContainer,
|
||||||
|
NoOptionsMessage,
|
||||||
|
}}
|
||||||
placeholder="Select a team"
|
placeholder="Select a team"
|
||||||
value={value}
|
loadingMessage={() => 'Loading...'}
|
||||||
autosize={true}
|
noOptionsMessage={() => 'No teams found'}
|
||||||
|
getOptionValue={i => i.id}
|
||||||
|
getOptionLabel={i => i.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import Select from 'react-select';
|
import AsyncSelect from 'react-select/lib/Async';
|
||||||
import PickerOption from './PickerOption';
|
import PickerOption from './PickerOption';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { User } from 'app/types';
|
import { User } from 'app/types';
|
||||||
|
import ResetStyles from './ResetStyles';
|
||||||
|
import IndicatorsContainer from './IndicatorsContainer';
|
||||||
|
import NoOptionsMessage from './NoOptionsMessage';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onSelected: (user: User) => void;
|
onSelected: (user: User) => void;
|
||||||
value?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,20 +33,17 @@ export class UserPicker extends Component<Props, State> {
|
|||||||
|
|
||||||
search(query?: string) {
|
search(query?: string) {
|
||||||
const backendSrv = getBackendSrv();
|
const backendSrv = getBackendSrv();
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
|
|
||||||
return backendSrv
|
return backendSrv
|
||||||
.get(`/api/org/users?query=${query}&limit=10`)
|
.get(`/api/org/users?query=${query}&limit=10`)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
return {
|
return result.map(user => ({
|
||||||
options: result.map(user => ({
|
|
||||||
id: user.userId,
|
id: user.userId,
|
||||||
label: `${user.login} - ${user.email}`,
|
label: `${user.login} - ${user.email}`,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
login: user.login,
|
login: user.login,
|
||||||
})),
|
}));
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.setState({ isLoading: false });
|
this.setState({ isLoading: false });
|
||||||
@ -52,26 +51,30 @@ export class UserPicker extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { value, className } = this.props;
|
const { className, onSelected } = this.props;
|
||||||
const { isLoading } = this.state;
|
const { isLoading } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-picker">
|
<div className="user-picker">
|
||||||
<Select.Async
|
<AsyncSelect
|
||||||
valueKey="id"
|
classNamePrefix={`gf-form-select-box`}
|
||||||
multi={false}
|
isMulti={false}
|
||||||
labelKey="label"
|
|
||||||
cache={false}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
defaultOptions={true}
|
||||||
loadOptions={this.debouncedSearch}
|
loadOptions={this.debouncedSearch}
|
||||||
loadingPlaceholder="Loading..."
|
onChange={onSelected}
|
||||||
noResultsText="No users found"
|
|
||||||
onChange={this.props.onSelected}
|
|
||||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||||
optionComponent={PickerOption}
|
styles={ResetStyles}
|
||||||
|
components={{
|
||||||
|
Option: PickerOption,
|
||||||
|
IndicatorsContainer,
|
||||||
|
NoOptionsMessage,
|
||||||
|
}}
|
||||||
placeholder="Select user"
|
placeholder="Select user"
|
||||||
value={value}
|
loadingMessage={() => 'Loading...'}
|
||||||
autosize={true}
|
noOptionsMessage={() => 'No users found'}
|
||||||
|
getOptionValue={i => i.id}
|
||||||
|
getOptionLabel={i => i.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`PickerOption renders correctly 1`] = `
|
exports[`PickerOption renders correctly 1`] = `
|
||||||
<button
|
<div>
|
||||||
className="user-picker-option__button btn btn-link class-for-user-picker"
|
<div
|
||||||
onMouseDown={[Function]}
|
className="description-picker-option__button btn btn-link class-for-user-picker"
|
||||||
onMouseEnter={[Function]}
|
>
|
||||||
onMouseMove={[Function]}
|
|
||||||
title="Model title"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
alt="User picker label"
|
alt="User picker label"
|
||||||
className="user-picker-option__avatar"
|
className="user-picker-option__avatar"
|
||||||
src="url/to/avatar"
|
src="url/to/avatar"
|
||||||
/>
|
/>
|
||||||
</button>
|
Model title
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -5,27 +5,27 @@ exports[`TeamPicker renders correctly 1`] = `
|
|||||||
className="user-picker"
|
className="user-picker"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||||
|
onKeyDown={[Function]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-control"
|
className="css-0 gf-form-select-box__control"
|
||||||
onKeyDown={[Function]}
|
|
||||||
onMouseDown={[Function]}
|
onMouseDown={[Function]}
|
||||||
onTouchEnd={[Function]}
|
onTouchEnd={[Function]}
|
||||||
onTouchMove={[Function]}
|
|
||||||
onTouchStart={[Function]}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-multi-value-wrapper"
|
className="css-0 gf-form-select-box__value-container"
|
||||||
id="react-select-2--value"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-placeholder"
|
className="css-0 gf-form-select-box__placeholder"
|
||||||
>
|
>
|
||||||
Loading...
|
Select a team
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="Select-input"
|
className="css-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-box__input"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "inline-block",
|
"display": "inline-block",
|
||||||
@ -33,21 +33,60 @@ exports[`TeamPicker renders correctly 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-activedescendant="react-select-2--value"
|
aria-autocomplete="list"
|
||||||
aria-expanded="false"
|
autoCapitalize="none"
|
||||||
aria-haspopup="false"
|
autoComplete="off"
|
||||||
aria-owns=""
|
autoCorrect="off"
|
||||||
|
disabled={false}
|
||||||
|
id="react-select-2-input"
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
required={false}
|
spellCheck="false"
|
||||||
role="combobox"
|
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
|
"background": 0,
|
||||||
|
"border": 0,
|
||||||
"boxSizing": "content-box",
|
"boxSizing": "content-box",
|
||||||
"width": "5px",
|
"color": "inherit",
|
||||||
|
"fontSize": "inherit",
|
||||||
|
"opacity": 1,
|
||||||
|
"outline": 0,
|
||||||
|
"padding": 0,
|
||||||
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tabIndex="0"
|
||||||
|
theme={
|
||||||
|
Object {
|
||||||
|
"borderRadius": 4,
|
||||||
|
"colors": Object {
|
||||||
|
"danger": "#DE350B",
|
||||||
|
"dangerLight": "#FFBDAD",
|
||||||
|
"neutral0": "hsl(0, 0%, 100%)",
|
||||||
|
"neutral10": "hsl(0, 0%, 90%)",
|
||||||
|
"neutral20": "hsl(0, 0%, 80%)",
|
||||||
|
"neutral30": "hsl(0, 0%, 70%)",
|
||||||
|
"neutral40": "hsl(0, 0%, 60%)",
|
||||||
|
"neutral5": "hsl(0, 0%, 95%)",
|
||||||
|
"neutral50": "hsl(0, 0%, 50%)",
|
||||||
|
"neutral60": "hsl(0, 0%, 40%)",
|
||||||
|
"neutral70": "hsl(0, 0%, 30%)",
|
||||||
|
"neutral80": "hsl(0, 0%, 20%)",
|
||||||
|
"neutral90": "hsl(0, 0%, 10%)",
|
||||||
|
"primary": "#2684FF",
|
||||||
|
"primary25": "#DEEBFF",
|
||||||
|
"primary50": "#B2D4FF",
|
||||||
|
"primary75": "#4C9AFF",
|
||||||
|
},
|
||||||
|
"spacing": Object {
|
||||||
|
"baseUnit": 4,
|
||||||
|
"controlHeight": 38,
|
||||||
|
"menuGutter": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="text"
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -67,23 +106,14 @@ exports[`TeamPicker renders correctly 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
</div>
|
||||||
aria-hidden="true"
|
<div
|
||||||
className="Select-loading-zone"
|
className="css-0 gf-form-select-box__indicators"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="Select-loading"
|
className="gf-form-select-box__select-arrow "
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
<span
|
|
||||||
className="Select-arrow-zone"
|
|
||||||
onMouseDown={[Function]}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="Select-arrow"
|
|
||||||
onMouseDown={[Function]}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,27 +5,27 @@ exports[`UserPicker renders correctly 1`] = `
|
|||||||
className="user-picker"
|
className="user-picker"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||||
|
onKeyDown={[Function]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-control"
|
className="css-0 gf-form-select-box__control"
|
||||||
onKeyDown={[Function]}
|
|
||||||
onMouseDown={[Function]}
|
onMouseDown={[Function]}
|
||||||
onTouchEnd={[Function]}
|
onTouchEnd={[Function]}
|
||||||
onTouchMove={[Function]}
|
|
||||||
onTouchStart={[Function]}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-multi-value-wrapper"
|
className="css-0 gf-form-select-box__value-container"
|
||||||
id="react-select-2--value"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="Select-placeholder"
|
className="css-0 gf-form-select-box__placeholder"
|
||||||
>
|
>
|
||||||
Loading...
|
Select user
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="Select-input"
|
className="css-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-box__input"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "inline-block",
|
"display": "inline-block",
|
||||||
@ -33,21 +33,60 @@ exports[`UserPicker renders correctly 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-activedescendant="react-select-2--value"
|
aria-autocomplete="list"
|
||||||
aria-expanded="false"
|
autoCapitalize="none"
|
||||||
aria-haspopup="false"
|
autoComplete="off"
|
||||||
aria-owns=""
|
autoCorrect="off"
|
||||||
|
disabled={false}
|
||||||
|
id="react-select-2-input"
|
||||||
onBlur={[Function]}
|
onBlur={[Function]}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onFocus={[Function]}
|
onFocus={[Function]}
|
||||||
required={false}
|
spellCheck="false"
|
||||||
role="combobox"
|
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
|
"background": 0,
|
||||||
|
"border": 0,
|
||||||
"boxSizing": "content-box",
|
"boxSizing": "content-box",
|
||||||
"width": "5px",
|
"color": "inherit",
|
||||||
|
"fontSize": "inherit",
|
||||||
|
"opacity": 1,
|
||||||
|
"outline": 0,
|
||||||
|
"padding": 0,
|
||||||
|
"width": "1px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tabIndex="0"
|
||||||
|
theme={
|
||||||
|
Object {
|
||||||
|
"borderRadius": 4,
|
||||||
|
"colors": Object {
|
||||||
|
"danger": "#DE350B",
|
||||||
|
"dangerLight": "#FFBDAD",
|
||||||
|
"neutral0": "hsl(0, 0%, 100%)",
|
||||||
|
"neutral10": "hsl(0, 0%, 90%)",
|
||||||
|
"neutral20": "hsl(0, 0%, 80%)",
|
||||||
|
"neutral30": "hsl(0, 0%, 70%)",
|
||||||
|
"neutral40": "hsl(0, 0%, 60%)",
|
||||||
|
"neutral5": "hsl(0, 0%, 95%)",
|
||||||
|
"neutral50": "hsl(0, 0%, 50%)",
|
||||||
|
"neutral60": "hsl(0, 0%, 40%)",
|
||||||
|
"neutral70": "hsl(0, 0%, 30%)",
|
||||||
|
"neutral80": "hsl(0, 0%, 20%)",
|
||||||
|
"neutral90": "hsl(0, 0%, 10%)",
|
||||||
|
"primary": "#2684FF",
|
||||||
|
"primary25": "#DEEBFF",
|
||||||
|
"primary50": "#B2D4FF",
|
||||||
|
"primary75": "#4C9AFF",
|
||||||
|
},
|
||||||
|
"spacing": Object {
|
||||||
|
"baseUnit": 4,
|
||||||
|
"controlHeight": 38,
|
||||||
|
"menuGutter": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type="text"
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -67,23 +106,14 @@ exports[`UserPicker renders correctly 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
</div>
|
||||||
aria-hidden="true"
|
<div
|
||||||
className="Select-loading-zone"
|
className="css-0 gf-form-select-box__indicators"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="Select-loading"
|
className="gf-form-select-box__select-arrow "
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
<span
|
|
||||||
className="Select-arrow-zone"
|
|
||||||
onMouseDown={[Function]}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="Select-arrow"
|
|
||||||
onMouseDown={[Function]}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,17 +5,12 @@ export interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
removeIcon: boolean;
|
removeIcon: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
onClick: any;
|
onClick?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TagBadge extends React.Component<Props, any> {
|
export class TagBadge extends React.Component<Props, any> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.onClick = this.onClick.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(event) {
|
|
||||||
this.props.onClick(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -28,7 +23,7 @@ export class TagBadge extends React.Component<Props, any> {
|
|||||||
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
|
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
|
<span className={`label label-tag`} style={tagStyle}>
|
||||||
{removeIcon && <i className="fa fa-remove" />}
|
{removeIcon && <i className="fa fa-remove" />}
|
||||||
{label} {countLabel}
|
{label} {countLabel}
|
||||||
</span>
|
</span>
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Async } from 'react-select';
|
import AsyncSelect from 'react-select/lib/Async';
|
||||||
import { TagValue } from './TagValue';
|
|
||||||
import { TagOption } from './TagOption';
|
import { TagOption } from './TagOption';
|
||||||
|
import { TagBadge } from './TagBadge';
|
||||||
|
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
|
||||||
|
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
import ResetStyles from 'app/core/components/Picker/ResetStyles';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@ -18,15 +21,15 @@ export class TagFilter extends React.Component<Props, any> {
|
|||||||
|
|
||||||
this.searchTags = this.searchTags.bind(this);
|
this.searchTags = this.searchTags.bind(this);
|
||||||
this.onChange = this.onChange.bind(this);
|
this.onChange = this.onChange.bind(this);
|
||||||
this.onTagRemove = this.onTagRemove.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTags(query) {
|
searchTags(query) {
|
||||||
return this.props.tagOptions().then(options => {
|
return this.props.tagOptions().then(options => {
|
||||||
const tags = _.map(options, tagOption => {
|
return options.map(option => ({
|
||||||
return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
|
value: option.term,
|
||||||
});
|
label: option.term,
|
||||||
return { options: tags };
|
count: option.count,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,33 +37,44 @@ export class TagFilter extends React.Component<Props, any> {
|
|||||||
this.props.onSelect(newTags);
|
this.props.onSelect(newTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
onTagRemove(tag) {
|
|
||||||
let newTags = _.without(this.props.tags, tag.label);
|
|
||||||
newTags = _.map(newTags, tag => {
|
|
||||||
return { value: tag };
|
|
||||||
});
|
|
||||||
this.props.onSelect(newTags);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const selectOptions = {
|
const selectOptions = {
|
||||||
|
classNamePrefix: 'gf-form-select-box',
|
||||||
|
isMulti: true,
|
||||||
|
defaultOptions: true,
|
||||||
loadOptions: this.searchTags,
|
loadOptions: this.searchTags,
|
||||||
onChange: this.onChange,
|
onChange: this.onChange,
|
||||||
value: this.props.tags,
|
|
||||||
multi: true,
|
|
||||||
className: 'gf-form-input gf-form-input--form-dropdown',
|
className: 'gf-form-input gf-form-input--form-dropdown',
|
||||||
placeholder: 'Tags',
|
placeholder: 'Tags',
|
||||||
loadingPlaceholder: 'Loading...',
|
loadingMessage: () => 'Loading...',
|
||||||
noResultsText: 'No tags found',
|
noOptionsMessage: () => 'No tags found',
|
||||||
optionComponent: TagOption,
|
getOptionValue: i => i.value,
|
||||||
};
|
getOptionLabel: i => i.label,
|
||||||
|
value: this.props.tags,
|
||||||
|
styles: ResetStyles,
|
||||||
|
components: {
|
||||||
|
Option: TagOption,
|
||||||
|
IndicatorsContainer,
|
||||||
|
NoOptionsMessage,
|
||||||
|
MultiValueLabel: () => {
|
||||||
|
return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
|
||||||
|
},
|
||||||
|
MultiValueRemove: props => {
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
selectOptions['valueComponent'] = TagValue;
|
return (
|
||||||
|
<components.MultiValueRemove {...props}>
|
||||||
|
<TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
|
||||||
|
</components.MultiValueRemove>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gf-form gf-form--has-input-icon gf-form--grow">
|
<div className="gf-form gf-form--has-input-icon gf-form--grow">
|
||||||
<div className="tag-filter">
|
<div className="tag-filter">
|
||||||
<Async {...selectOptions} />
|
<AsyncSelect {...selectOptions} />
|
||||||
</div>
|
</div>
|
||||||
<i className="gf-form-input-icon fa fa-tag" />
|
<i className="gf-form-input-icon fa fa-tag" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,52 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
import { OptionProps } from 'react-select/lib/components/Option';
|
||||||
import { TagBadge } from './TagBadge';
|
import { TagBadge } from './TagBadge';
|
||||||
|
|
||||||
export interface Props {
|
// https://github.com/JedWatson/react-select/issues/3038
|
||||||
onSelect: any;
|
interface ExtendedOptionProps extends OptionProps<any> {
|
||||||
onFocus: any;
|
data: any;
|
||||||
option: any;
|
|
||||||
isFocused: any;
|
|
||||||
className: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TagOption extends React.Component<Props, any> {
|
export const TagOption = (props: ExtendedOptionProps) => {
|
||||||
constructor(props) {
|
const { data, className, label } = 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, className } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<components.Option {...props}>
|
||||||
onMouseDown={this.handleMouseDown}
|
<div className={`tag-filter-option btn btn-link ${className || ''}`}>
|
||||||
onMouseEnter={this.handleMouseEnter}
|
<TagBadge label={label} removeIcon={false} count={data.count} />
|
||||||
onMouseMove={this.handleMouseMove}
|
</div>
|
||||||
title={option.title}
|
</components.Option>
|
||||||
className={`tag-filter-option btn btn-link ${className || ''}`}
|
|
||||||
>
|
|
||||||
<TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
export default TagOption;
|
||||||
|
@ -21,6 +21,6 @@ export class TagValue extends React.Component<Props, any> {
|
|||||||
render() {
|
render() {
|
||||||
const { value } = this.props;
|
const { value } = this.props;
|
||||||
|
|
||||||
return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
|
return <TagBadge label={value.label} removeIcon={false} count={0} onClick={this.onClick} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
|
|||||||
const template =
|
const template =
|
||||||
'<move-to-folder-modal dismiss="dismiss()" ' +
|
'<move-to-folder-modal dismiss="dismiss()" ' +
|
||||||
'dashboards="model.dashboards" after-save="model.afterSave()">' +
|
'dashboards="model.dashboards" after-save="model.afterSave()">' +
|
||||||
'</move-to-folder-modal>`';
|
'</move-to-folder-modal>';
|
||||||
appEvents.emit('show-modal', {
|
appEvents.emit('show-modal', {
|
||||||
templateHtml: template,
|
templateHtml: template,
|
||||||
modalClass: 'modal--narrow',
|
modalClass: 'modal--narrow',
|
||||||
|
@ -160,8 +160,12 @@ export class SearchCtrl {
|
|||||||
searchDashboards() {
|
searchDashboards() {
|
||||||
this.currentSearchId = this.currentSearchId + 1;
|
this.currentSearchId = this.currentSearchId + 1;
|
||||||
const localSearchId = this.currentSearchId;
|
const localSearchId = this.currentSearchId;
|
||||||
|
const query = {
|
||||||
|
...this.query,
|
||||||
|
tag: this.query.tag.map(i => i.value),
|
||||||
|
};
|
||||||
|
|
||||||
return this.searchSrv.search(this.query).then(results => {
|
return this.searchSrv.search(query).then(results => {
|
||||||
if (localSearchId < this.currentSearchId) {
|
if (localSearchId < this.currentSearchId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -196,7 +200,7 @@ export class SearchCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTagSelect(newTags) {
|
onTagSelect(newTags) {
|
||||||
this.query.tag = _.map(newTags, tag => tag.value);
|
this.query.tag = newTags;
|
||||||
this.search();
|
this.search();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
|
|||||||
render() {
|
render() {
|
||||||
return [
|
return [
|
||||||
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
||||||
<img src="public/img/grafana_icon.svg" alt="graphana_logo" />
|
<img src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||||
</div>,
|
</div>,
|
||||||
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
||||||
<i className="fa fa-bars" />
|
<i className="fa fa-bars" />
|
||||||
|
@ -8,7 +8,7 @@ Array [
|
|||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="graphana_logo"
|
alt="Grafana"
|
||||||
src="public/img/grafana_icon.svg"
|
src="public/img/grafana_icon.svg"
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
|
@ -399,6 +399,77 @@ describe('duration', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clock', () => {
|
||||||
|
it('null', () => {
|
||||||
|
const str = kbn.toClock(null, 0);
|
||||||
|
expect(str).toBe('');
|
||||||
|
});
|
||||||
|
it('size less than 1 second', () => {
|
||||||
|
const str = kbn.toClock(999, 0);
|
||||||
|
expect(str).toBe('999ms');
|
||||||
|
});
|
||||||
|
describe('size less than 1 minute', () => {
|
||||||
|
it('default', () => {
|
||||||
|
const str = kbn.toClock(59999);
|
||||||
|
expect(str).toBe('59s:999ms');
|
||||||
|
});
|
||||||
|
it('decimals equals 0', () => {
|
||||||
|
const str = kbn.toClock(59999, 0);
|
||||||
|
expect(str).toBe('59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('size less than 1 hour', () => {
|
||||||
|
it('default', () => {
|
||||||
|
const str = kbn.toClock(3599999);
|
||||||
|
expect(str).toBe('59m:59s:999ms');
|
||||||
|
});
|
||||||
|
it('decimals equals 0', () => {
|
||||||
|
const str = kbn.toClock(3599999, 0);
|
||||||
|
expect(str).toBe('59m');
|
||||||
|
});
|
||||||
|
it('decimals equals 1', () => {
|
||||||
|
const str = kbn.toClock(3599999, 1);
|
||||||
|
expect(str).toBe('59m:59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('size greater than or equal 1 hour', () => {
|
||||||
|
it('default', () => {
|
||||||
|
const str = kbn.toClock(7199999);
|
||||||
|
expect(str).toBe('01h:59m:59s:999ms');
|
||||||
|
});
|
||||||
|
it('decimals equals 0', () => {
|
||||||
|
const str = kbn.toClock(7199999, 0);
|
||||||
|
expect(str).toBe('01h');
|
||||||
|
});
|
||||||
|
it('decimals equals 1', () => {
|
||||||
|
const str = kbn.toClock(7199999, 1);
|
||||||
|
expect(str).toBe('01h:59m');
|
||||||
|
});
|
||||||
|
it('decimals equals 2', () => {
|
||||||
|
const str = kbn.toClock(7199999, 2);
|
||||||
|
expect(str).toBe('01h:59m:59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('size greater than or equal 1 day', () => {
|
||||||
|
it('default', () => {
|
||||||
|
const str = kbn.toClock(89999999);
|
||||||
|
expect(str).toBe('24h:59m:59s:999ms');
|
||||||
|
});
|
||||||
|
it('decimals equals 0', () => {
|
||||||
|
const str = kbn.toClock(89999999, 0);
|
||||||
|
expect(str).toBe('24h');
|
||||||
|
});
|
||||||
|
it('decimals equals 1', () => {
|
||||||
|
const str = kbn.toClock(89999999, 1);
|
||||||
|
expect(str).toBe('24h:59m');
|
||||||
|
});
|
||||||
|
it('decimals equals 2', () => {
|
||||||
|
const str = kbn.toClock(89999999, 2);
|
||||||
|
expect(str).toBe('24h:59m:59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('volume', () => {
|
describe('volume', () => {
|
||||||
it('1000m3', () => {
|
it('1000m3', () => {
|
||||||
const str = kbn.valueFormats['m3'](1000, 1, null);
|
const str = kbn.valueFormats['m3'](1000, 1, null);
|
||||||
|
@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
|
|||||||
const actual = nodeH.getOptimizedInputEdges();
|
const actual = nodeH.getOptimizedInputEdges();
|
||||||
expect(actual).toHaveLength(0);
|
expect(actual).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('when linking non-existing input node with existing output node should throw error', () => {
|
||||||
|
expect(() => {
|
||||||
|
dag.link('non-existing', 'A');
|
||||||
|
}).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when linking existing input node with non-existing output node should throw error', () => {
|
||||||
|
expect(() => {
|
||||||
|
dag.link('A', 'non-existing');
|
||||||
|
}).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -15,6 +15,14 @@ export class Edge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
link(inputNode: Node, outputNode: Node) {
|
link(inputNode: Node, outputNode: Node) {
|
||||||
|
if (!inputNode) {
|
||||||
|
throw Error('inputNode is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputNode) {
|
||||||
|
throw Error('outputNode is required');
|
||||||
|
}
|
||||||
|
|
||||||
this.unlink();
|
this.unlink();
|
||||||
this.inputNode = inputNode;
|
this.inputNode = inputNode;
|
||||||
this.outputNode = outputNode;
|
this.outputNode = outputNode;
|
||||||
@ -152,7 +160,11 @@ export class Graph {
|
|||||||
for (let n = 0; n < inputArr.length; n++) {
|
for (let n = 0; n < inputArr.length; n++) {
|
||||||
const i = inputArr[n];
|
const i = inputArr[n];
|
||||||
if (typeof i === 'string') {
|
if (typeof i === 'string') {
|
||||||
inputNodes.push(this.getNode(i));
|
const n = this.getNode(i);
|
||||||
|
if (!n) {
|
||||||
|
throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
|
||||||
|
}
|
||||||
|
inputNodes.push(n);
|
||||||
} else {
|
} else {
|
||||||
inputNodes.push(i);
|
inputNodes.push(i);
|
||||||
}
|
}
|
||||||
@ -161,7 +173,11 @@ export class Graph {
|
|||||||
for (let n = 0; n < outputArr.length; n++) {
|
for (let n = 0; n < outputArr.length; n++) {
|
||||||
const i = outputArr[n];
|
const i = outputArr[n];
|
||||||
if (typeof i === 'string') {
|
if (typeof i === 'string') {
|
||||||
outputNodes.push(this.getNode(i));
|
const n = this.getNode(i);
|
||||||
|
if (!n) {
|
||||||
|
throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
|
||||||
|
}
|
||||||
|
outputNodes.push(n);
|
||||||
} else {
|
} else {
|
||||||
outputNodes.push(i);
|
outputNodes.push(i);
|
||||||
}
|
}
|
||||||
|
@ -808,6 +808,51 @@ kbn.toDuration = (size, decimals, timeScale) => {
|
|||||||
return strings.join(', ');
|
return strings.join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
kbn.toClock = (size, decimals) => {
|
||||||
|
if (size === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 1 second
|
||||||
|
if (size < 1000) {
|
||||||
|
return moment.utc(size).format('SSS\\m\\s');
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 1 minute
|
||||||
|
if (size < 60000) {
|
||||||
|
let format = 'ss\\s:SSS\\m\\s';
|
||||||
|
if (decimals === 0) {
|
||||||
|
format = 'ss\\s';
|
||||||
|
}
|
||||||
|
return moment.utc(size).format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 1 hour
|
||||||
|
if (size < 3600000) {
|
||||||
|
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||||
|
if (decimals === 0) {
|
||||||
|
format = 'mm\\m';
|
||||||
|
} else if (decimals === 1) {
|
||||||
|
format = 'mm\\m:ss\\s';
|
||||||
|
}
|
||||||
|
return moment.utc(size).format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||||
|
|
||||||
|
const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
|
||||||
|
|
||||||
|
if (decimals === 0) {
|
||||||
|
format = '';
|
||||||
|
} else if (decimals === 1) {
|
||||||
|
format = 'mm\\m';
|
||||||
|
} else if (decimals === 2) {
|
||||||
|
format = 'mm\\m:ss\\s';
|
||||||
|
}
|
||||||
|
|
||||||
|
return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
|
||||||
|
};
|
||||||
|
|
||||||
kbn.valueFormats.dtdurationms = (size, decimals) => {
|
kbn.valueFormats.dtdurationms = (size, decimals) => {
|
||||||
return kbn.toDuration(size, decimals, 'millisecond');
|
return kbn.toDuration(size, decimals, 'millisecond');
|
||||||
};
|
};
|
||||||
@ -824,6 +869,14 @@ kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
|
|||||||
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
|
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
kbn.valueFormats.clockms = (size, decimals) => {
|
||||||
|
return kbn.toClock(size, decimals);
|
||||||
|
};
|
||||||
|
|
||||||
|
kbn.valueFormats.clocks = (size, decimals) => {
|
||||||
|
return kbn.toClock(size * 1000, decimals);
|
||||||
|
};
|
||||||
|
|
||||||
kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
|
kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
|
||||||
const time = isUtc ? moment.utc(epoch) : moment(epoch);
|
const time = isUtc ? moment.utc(epoch) : moment(epoch);
|
||||||
|
|
||||||
@ -901,6 +954,8 @@ kbn.getUnitFormats = () => {
|
|||||||
{ text: 'duration (s)', value: 'dtdurations' },
|
{ text: 'duration (s)', value: 'dtdurations' },
|
||||||
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
|
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
|
||||||
{ text: 'Timeticks (s/100)', value: 'timeticks' },
|
{ text: 'Timeticks (s/100)', value: 'timeticks' },
|
||||||
|
{ text: 'clock (ms)', value: 'clockms' },
|
||||||
|
{ text: 'clock (s)', value: 'clocks' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl {
|
|||||||
defaults: any = {
|
defaults: any = {
|
||||||
type: 'email',
|
type: 'email',
|
||||||
sendReminder: false,
|
sendReminder: false,
|
||||||
|
disableResolveMessage: false,
|
||||||
frequency: '15m',
|
frequency: '15m',
|
||||||
settings: {
|
settings: {
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
|
@ -21,21 +21,28 @@
|
|||||||
<gf-form-switch
|
<gf-form-switch
|
||||||
class="gf-form"
|
class="gf-form"
|
||||||
label="Send on all alerts"
|
label="Send on all alerts"
|
||||||
label-class="width-12"
|
label-class="width-14"
|
||||||
checked="ctrl.model.isDefault"
|
checked="ctrl.model.isDefault"
|
||||||
tooltip="Use this notification for all alerts">
|
tooltip="Use this notification for all alerts">
|
||||||
</gf-form-switch>
|
</gf-form-switch>
|
||||||
<gf-form-switch
|
<gf-form-switch
|
||||||
class="gf-form"
|
class="gf-form"
|
||||||
label="Include image"
|
label="Include image"
|
||||||
label-class="width-12"
|
label-class="width-14"
|
||||||
checked="ctrl.model.settings.uploadImage"
|
checked="ctrl.model.settings.uploadImage"
|
||||||
tooltip="Captures an image and include it in the notification">
|
tooltip="Captures an image and include it in the notification">
|
||||||
</gf-form-switch>
|
</gf-form-switch>
|
||||||
|
<gf-form-switch
|
||||||
|
class="gf-form"
|
||||||
|
label="Disable Resolve Message"
|
||||||
|
label-class="width-14"
|
||||||
|
checked="ctrl.model.disableResolveMessage"
|
||||||
|
tooltip="Disable the resolve message [OK] that is sent when alerting state returns to false">
|
||||||
|
</gf-form-switch>
|
||||||
<gf-form-switch
|
<gf-form-switch
|
||||||
class="gf-form"
|
class="gf-form"
|
||||||
label="Send reminders"
|
label="Send reminders"
|
||||||
label-class="width-12"
|
label-class="width-14"
|
||||||
checked="ctrl.model.sendReminder"
|
checked="ctrl.model.sendReminder"
|
||||||
tooltip="Send additional notifications for triggered alerts">
|
tooltip="Send additional notifications for triggered alerts">
|
||||||
</gf-form-switch>
|
</gf-form-switch>
|
||||||
|
@ -8,6 +8,7 @@ import { makeRegions, dedupAnnotations } from './events_processing';
|
|||||||
export class AnnotationsSrv {
|
export class AnnotationsSrv {
|
||||||
globalAnnotationsPromise: any;
|
globalAnnotationsPromise: any;
|
||||||
alertStatesPromise: any;
|
alertStatesPromise: any;
|
||||||
|
datasourcePromises: any;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
|
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
|
||||||
@ -18,6 +19,7 @@ export class AnnotationsSrv {
|
|||||||
clearCache() {
|
clearCache() {
|
||||||
this.globalAnnotationsPromise = null;
|
this.globalAnnotationsPromise = null;
|
||||||
this.alertStatesPromise = null;
|
this.alertStatesPromise = null;
|
||||||
|
this.datasourcePromises = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAnnotations(options) {
|
getAnnotations(options) {
|
||||||
@ -90,6 +92,7 @@ export class AnnotationsSrv {
|
|||||||
|
|
||||||
const range = this.timeSrv.timeRange();
|
const range = this.timeSrv.timeRange();
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
const dsPromises = [];
|
||||||
|
|
||||||
for (const annotation of dashboard.annotations.list) {
|
for (const annotation of dashboard.annotations.list) {
|
||||||
if (!annotation.enable) {
|
if (!annotation.enable) {
|
||||||
@ -99,10 +102,10 @@ export class AnnotationsSrv {
|
|||||||
if (annotation.snapshotData) {
|
if (annotation.snapshotData) {
|
||||||
return this.translateQueryResult(annotation, annotation.snapshotData);
|
return this.translateQueryResult(annotation, annotation.snapshotData);
|
||||||
}
|
}
|
||||||
|
const datasourcePromise = this.datasourceSrv.get(annotation.datasource);
|
||||||
|
dsPromises.push(datasourcePromise);
|
||||||
promises.push(
|
promises.push(
|
||||||
this.datasourceSrv
|
datasourcePromise
|
||||||
.get(annotation.datasource)
|
|
||||||
.then(datasource => {
|
.then(datasource => {
|
||||||
// issue query against data source
|
// issue query against data source
|
||||||
return datasource.annotationQuery({
|
return datasource.annotationQuery({
|
||||||
@ -122,7 +125,7 @@ export class AnnotationsSrv {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.datasourcePromises = this.$q.all(dsPromises);
|
||||||
this.globalAnnotationsPromise = this.$q.all(promises);
|
this.globalAnnotationsPromise = this.$q.all(promises);
|
||||||
return this.globalAnnotationsPromise;
|
return this.globalAnnotationsPromise;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
|
|||||||
navModel: {} as NavModel,
|
navModel: {} as NavModel,
|
||||||
apiKeys: [] as ApiKey[],
|
apiKeys: [] as ApiKey[],
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
hasFetched: false,
|
||||||
loadApiKeys: jest.fn(),
|
loadApiKeys: jest.fn(),
|
||||||
deleteApiKey: jest.fn(),
|
deleteApiKey: jest.fn(),
|
||||||
setSearchQuery: jest.fn(),
|
setSearchQuery: jest.fn(),
|
||||||
@ -35,6 +36,7 @@ describe('Render', () => {
|
|||||||
it('should render API keys table', () => {
|
it('should render API keys table', () => {
|
||||||
const { wrapper } = setup({
|
const { wrapper } = setup({
|
||||||
apiKeys: getMultipleMockKeys(5),
|
apiKeys: getMultipleMockKeys(5),
|
||||||
|
hasFetched: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
|
|||||||
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
||||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||||
|
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||||
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
@ -16,6 +17,7 @@ export interface Props {
|
|||||||
navModel: NavModel;
|
navModel: NavModel;
|
||||||
apiKeys: ApiKey[];
|
apiKeys: ApiKey[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
hasFetched: boolean;
|
||||||
loadApiKeys: typeof loadApiKeys;
|
loadApiKeys: typeof loadApiKeys;
|
||||||
deleteApiKey: typeof deleteApiKey;
|
deleteApiKey: typeof deleteApiKey;
|
||||||
setSearchQuery: typeof setSearchQuery;
|
setSearchQuery: typeof setSearchQuery;
|
||||||
@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderTable() {
|
||||||
|
const { apiKeys } = this.props;
|
||||||
|
|
||||||
|
return [
|
||||||
|
<h3 key="header" className="page-heading">
|
||||||
|
Existing Keys
|
||||||
|
</h3>,
|
||||||
|
<table key="table" className="filter-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th style={{ width: '34px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{apiKeys.length > 0 && (
|
||||||
|
<tbody>
|
||||||
|
{apiKeys.map(key => {
|
||||||
|
return (
|
||||||
|
<tr key={key.id}>
|
||||||
|
<td>{key.name}</td>
|
||||||
|
<td>{key.role}</td>
|
||||||
|
<td>
|
||||||
|
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||||
|
<i className="fa fa-remove" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
)}
|
||||||
|
</table>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { newApiKey, isAdding } = this.state;
|
const { newApiKey, isAdding } = this.state;
|
||||||
const { navModel, apiKeys, searchQuery } = this.props;
|
const { hasFetched, navModel, searchQuery } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</SlideDown>
|
</SlideDown>
|
||||||
|
{hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
|
||||||
<h3 className="page-heading">Existing Keys</h3>
|
|
||||||
<table className="filter-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th style={{ width: '34px' }} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{apiKeys.length > 0 ? (
|
|
||||||
<tbody>
|
|
||||||
{apiKeys.map(key => {
|
|
||||||
return (
|
|
||||||
<tr key={key.id}>
|
|
||||||
<td>{key.name}</td>
|
|
||||||
<td>{key.role}</td>
|
|
||||||
<td>
|
|
||||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
|
||||||
<i className="fa fa-remove" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
) : null}
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -209,6 +220,7 @@ function mapStateToProps(state) {
|
|||||||
navModel: getNavModel(state.navIndex, 'apikeys'),
|
navModel: getNavModel(state.navIndex, 'apikeys'),
|
||||||
apiKeys: getApiKeys(state.apiKeys),
|
apiKeys: getApiKeys(state.apiKeys),
|
||||||
searchQuery: state.apiKeys.searchQuery,
|
searchQuery: state.apiKeys.searchQuery,
|
||||||
|
hasFetched: state.apiKeys.hasFetched,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
|
|||||||
</Component>
|
</Component>
|
||||||
<h3
|
<h3
|
||||||
className="page-heading"
|
className="page-heading"
|
||||||
|
key="header"
|
||||||
>
|
>
|
||||||
Existing Keys
|
Existing Keys
|
||||||
</h3>
|
</h3>
|
||||||
<table
|
<table
|
||||||
className="filter-table"
|
className="filter-table"
|
||||||
|
key="table"
|
||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Component>
|
</Component>
|
||||||
<h3
|
<PageLoader
|
||||||
className="page-heading"
|
pageName="Api keys"
|
||||||
>
|
|
||||||
Existing Keys
|
|
||||||
</h3>
|
|
||||||
<table
|
|
||||||
className="filter-table"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Role
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={
|
|
||||||
Object {
|
|
||||||
"width": "34px",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
|
|||||||
export const initialApiKeysState: ApiKeysState = {
|
export const initialApiKeysState: ApiKeysState = {
|
||||||
keys: [],
|
keys: [],
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
hasFetched: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
|
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionTypes.LoadApiKeys:
|
case ActionTypes.LoadApiKeys:
|
||||||
return { ...state, keys: action.payload };
|
return { ...state, hasFetched: true, keys: action.payload };
|
||||||
case ActionTypes.SetApiKeysSearchQuery:
|
case ActionTypes.SetApiKeysSearchQuery:
|
||||||
return { ...state, searchQuery: action.payload };
|
return { ...state, searchQuery: action.payload };
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
|
|||||||
const mockKeys = getMultipleMockKeys(5);
|
const mockKeys = getMultipleMockKeys(5);
|
||||||
|
|
||||||
it('should return all keys if no search query', () => {
|
it('should return all keys if no search query', () => {
|
||||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
|
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
|
||||||
|
|
||||||
const keys = getApiKeys(mockState);
|
const keys = getApiKeys(mockState);
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should filter keys if search query exists', () => {
|
it('should filter keys if search query exists', () => {
|
||||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
|
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
|
||||||
|
|
||||||
const keys = getApiKeys(mockState);
|
const keys = getApiKeys(mockState);
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
setDataSourcesSearchQuery: jest.fn(),
|
setDataSourcesSearchQuery: jest.fn(),
|
||||||
setDataSourcesLayoutMode: jest.fn(),
|
setDataSourcesLayoutMode: jest.fn(),
|
||||||
|
hasFetched: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
@ -33,6 +34,7 @@ describe('Render', () => {
|
|||||||
const wrapper = setup({
|
const wrapper = setup({
|
||||||
dataSources: getMockDataSources(5),
|
dataSources: getMockDataSources(5),
|
||||||
dataSourcesCount: 5,
|
dataSourcesCount: 5,
|
||||||
|
hasFetched: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user