Merge branch 'master' into data-source-settings-to-react

This commit is contained in:
Peter Holmberg 2018-10-22 14:49:41 +02:00
commit c92813f313
211 changed files with 3652 additions and 1767 deletions

View File

@ -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",

View File

@ -170,6 +170,7 @@ 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:
@ -237,8 +238,17 @@ jobs:
steps: steps:
- checkout - checkout
- run: - run:
name: build, test and package grafana enterprise name: prepare build tools
command: './scripts/build/build_enterprise.sh' command: '/tmp/bootstrap.sh'
- run:
name: checkout enterprise
command: './scripts/build/prepare_enterprise.sh'
- run:
name: test enterprise
command: 'go test ./pkg/extensions/...'
- run:
name: build and package enterprise
command: './scripts/build/build.sh -enterprise'
- run: - run:
name: sign packages name: sign packages
command: './scripts/build/sign_packages.sh' command: './scripts/build/sign_packages.sh'
@ -253,6 +263,53 @@ jobs:
paths: paths:
- enterprise-dist/grafana-enterprise* - enterprise-dist/grafana-enterprise*
build-all-enterprise:
docker:
- image: grafana/build-container:1.2.0
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- run:
name: prepare build tools
command: '/tmp/bootstrap.sh'
- run:
name: checkout enterprise
command: './scripts/build/prepare_enterprise.sh'
- restore_cache:
key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
- run:
name: download phantomjs binaries
command: './scripts/build/download-phantomjs.sh'
- save_cache:
key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
paths:
- /tmp/phantomjs
- run:
name: test enterprise
command: 'go test ./pkg/extensions/...'
- run:
name: build and package grafana
command: './scripts/build/build-all.sh -enterprise'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
- run:
name: verify signed packages
command: |
mkdir -p ~/.rpmdb/pubkeys
curl -s https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana > ~/.rpmdb/pubkeys/grafana.key
./scripts/build/verify_signed_packages.sh dist/*.rpm
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
- run:
name: move enterprise packages into their own folder
command: 'mv dist enterprise-dist'
- persist_to_workspace:
root: .
paths:
- enterprise-dist/grafana-enterprise*
deploy-enterprise-master: deploy-enterprise-master:
docker: docker:
- image: circleci/python:2.7-stretch - image: circleci/python:2.7-stretch
@ -266,6 +323,19 @@ jobs:
name: deploy to s3 name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master' command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
deploy-enterprise-release:
docker:
- image: circleci/python:2.7-stretch
steps:
- attach_workspace:
at: .
- run:
name: install awscli
command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
deploy-master: deploy-master:
docker: docker:
- image: circleci/python:2.7-stretch - image: circleci/python:2.7-stretch
@ -312,7 +382,7 @@ workflows:
jobs: jobs:
- build-all: - build-all:
filters: *filter-only-master filters: *filter-only-master
- build-enterprise: - build-all-enterprise:
filters: *filter-only-master filters: *filter-only-master
- codespell: - codespell:
filters: *filter-only-master filters: *filter-only-master
@ -355,13 +425,15 @@ workflows:
- gometalinter - gometalinter
- mysql-integration-test - mysql-integration-test
- postgres-integration-test - postgres-integration-test
- build-enterprise - build-all-enterprise
filters: *filter-only-master filters: *filter-only-master
release: release:
jobs: jobs:
- build-all: - build-all:
filters: *filter-only-release filters: *filter-only-release
- build-all-enterprise:
filters: *filter-only-release
- codespell: - codespell:
filters: *filter-only-release filters: *filter-only-release
- gometalinter: - gometalinter:
@ -384,6 +456,17 @@ workflows:
- mysql-integration-test - mysql-integration-test
- postgres-integration-test - postgres-integration-test
filters: *filter-only-release filters: *filter-only-release
- deploy-enterprise-release:
requires:
- build-all
- build-all-enterprise
- test-backend
- test-frontend
- codespell
- gometalinter
- mysql-integration-test
- postgres-integration-test
filters: *filter-only-release
- grafana-docker-release: - grafana-docker-release:
requires: requires:
- build-all - build-all

1
.gitignore vendored
View File

@ -69,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

View File

@ -2,20 +2,40 @@
### New Features ### New Features
* **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)
* **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)
* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
### Minor ### Minor
* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
* **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)
* **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)
* **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)
### 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 (unreleased) # 5.3.2 (unreleased)
* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm)
* **Postgres**: Fix template variables error [#13692](https://github.com/grafana/grafana/issues/13692), thx [@svenklemm](https://github.com/svenklemm)
* **Cloudwatch**: Fix service panic because of race conditions [#13674](https://github.com/grafana/grafana/issues/13674), thx [@mtanda](https://github.com/mtanda)
* **Stackdriver/Cloudwatch**: Allow user to change unit in graph panel if cloudwatch/stackdriver datasource response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda)
* **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f)
# 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) * **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)
@ -68,7 +88,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)

View File

@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies ### Dependencies
- Go 1.11 - Go (Latest Stable)
- NodeJS LTS - NodeJS LTS
### Building the backend ### Building the backend
@ -69,15 +69,27 @@ bra run
Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`). Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
### Building a docker image (on linux/amd64) ### Building a Docker image
This builds a docker image from your local sources: There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
#### Building on linux/amd64 (fast)
1. Build the frontend `go run build.go build-frontend` 1. Build the frontend `go run build.go build-frontend`
2. Build the docker image `make build-docker-dev` 2. Build the docker image `make build-docker-dev`
The resulting image will be tagged as `grafana/grafana:dev` The resulting image will be tagged as `grafana/grafana:dev`
#### Building anywhere (slower)
Choose this option to build on platforms other than linux/amd64 and/or not have to setup the Grafana development environment.
1. `make build-docker-full` or `docker build -t grafana/grafana:dev .`
The resulting image will be tagged as `grafana/grafana:dev`
### Dev config ### Dev config
Create a custom.ini in the conf directory to override default configuration options. Create a custom.ini in the conf directory to override default configuration options.
@ -113,18 +125,6 @@ GRAFANA_TEST_DB=mysql go test ./pkg/...
GRAFANA_TEST_DB=postgres go test ./pkg/... GRAFANA_TEST_DB=postgres go test ./pkg/...
``` ```
## Building custom docker image
You can build a custom image using Docker, which doesn't require installing any dependencies besides docker itself.
```bash
git clone https://github.com/grafana/grafana
cd grafana
docker build -t grafana:dev .
docker run -d --name=grafana -p 3000:3000 grafana:dev
```
Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
## Contribute ## Contribute
If you have any idea for an improvement or found a bug, do not hesitate to open an issue. If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
@ -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).

89
UPGRADING_DEPENDENCIES.md Normal file
View File

@ -0,0 +1,89 @@
# Guide to Upgrading Dependencies
Upgrading Go or Node.js requires making changes in many different files. See below for a list and explanation for each.
## Go
- CircleCi
- `grafana/build-container`
- Appveyor
- Dockerfile
## Node.js
- CircleCI
- `grafana/build-container`
- Appveyor
- Dockerfile
## Go Dependencies
Updated using `dep`.
- `Gopkg.toml`
- `Gopkg.lock`
## Node.js Dependencies
Updated using `yarn`.
- `package.json`
## Where to make changes
### CircleCI
Our builds run on CircleCI through our build script.
#### Files
- `.circleci/config.yml`.
#### Dependencies
- nodejs
- golang
- grafana/build-container (our custom docker build container)
### grafana/build-container
The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the neccesary dependencies.
Link: [grafana-build-container](https://github.com/grafana/grafana-build-container)
#### Dependencies
- fpm
- nodejs
- golang
- crosscompiling (several compilers)
### Appveyor
Master and release builds trigger test runs on Appveyors build environment so that tests will run on Windows.
#### Files:
- `appveyor.yml`
#### Dependencies
- nodejs
- golang
### Dockerfile
There is a Docker build for Grafana in the root of the project that allows anyone to build Grafana just using Docker.
#### Files
- `Dockerfile`
#### Dependencies
- nodejs
- golang
### Local developer environments
Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments.

View File

@ -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

View File

@ -554,3 +554,6 @@ container_name =
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer # Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
server_url = server_url =
callback_url = callback_url =
[panels]
enable_alpha = false

View File

@ -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+) |

View File

@ -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".

View File

@ -46,7 +46,7 @@ Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGu
## IAM Policies ## IAM Policies
Grafana needs permissions granted via IAM to be able to read CloudWatch metrics Grafana needs permissions granted via IAM to be able to read CloudWatch metrics
and EC2 tags/instances. You can attach these permissions to IAM roles and and EC2 tags/instances/regions. You can attach these permissions to IAM roles and
utilize Grafana's built-in support for assuming roles. utilize Grafana's built-in support for assuming roles.
Here is a minimal policy example: Here is a minimal policy example:
@ -65,11 +65,12 @@ Here is a minimal policy example:
"Resource": "*" "Resource": "*"
}, },
{ {
"Sid": "AllowReadingTagsFromEC2", "Sid": "AllowReadingTagsInstancesRegionsFromEC2",
"Effect": "Allow", "Effect": "Allow",
"Action": [ "Action": [
"ec2:DescribeTags", "ec2:DescribeTags",
"ec2:DescribeInstances" "ec2:DescribeInstances",
"ec2:DescribeRegions"
], ],
"Resource": "*" "Resource": "*"
} }

View File

@ -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

View File

@ -206,6 +206,7 @@ datasources:
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
defaultProject: my-project-name
secureJsonData: secureJsonData:
privateKey: | privateKey: |
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----

View File

@ -87,7 +87,7 @@ docker run \
## Building a custom Grafana image with pre-installed plugins ## Building a custom Grafana image with pre-installed plugins
In the [grafana-docker](https://github.com/grafana/grafana-docker/) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments. In the [grafana-docker](https://github.com/grafana/grafana/tree/master/packaging/docker) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
Example of how to build and run: Example of how to build and run:
```bash ```bash
@ -103,6 +103,21 @@ docker run \
grafana:latest-with-plugins grafana:latest-with-plugins
``` ```
## Installing Plugins from other sources
> Only available in Grafana v5.3.1+
It's possible to install plugins from custom url:s by specifying the url like this: `GF_INSTALL_PLUGINS=<url to plugin zip>;<plugin name>`
```bash
docker run \
-d \
-p 3000:3000 \
--name=grafana \
-e "GF_INSTALL_PLUGINS=http://plugin-domain.com/my-custom-plugin.zip;custom-plugin" \
grafana/grafana
```
## Configuring AWS Credentials for CloudWatch Support ## Configuring AWS Credentials for CloudWatch Support
```bash ```bash

View File

@ -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" >}}).

View File

@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple
## Dependencies ## Dependencies
- [Go 1.11](https://golang.org/dl/) - [Go (Latest Stable)](https://golang.org/dl/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [NodeJS LTS](https://nodejs.org/download/) - [NodeJS LTS](https://nodejs.org/download/)
- node-gyp is the Node.js native addon build tool and it requires extra dependencies: python 2.7, make and GCC. These are already installed for most Linux distros and MacOS. See the Building On Windows section or the [node-gyp installation instructions](https://github.com/nodejs/node-gyp#installation) for more details. - node-gyp is the Node.js native addon build tool and it requires extra dependencies: python 2.7, make and GCC. These are already installed for most Linux distros and MacOS. See the Building On Windows section or the [node-gyp installation instructions](https://github.com/nodejs/node-gyp#installation) for more details.

View File

@ -1,4 +1,4 @@
{ {
"stable": "5.3.0", "stable": "5.3.1",
"testing": "5.3.0" "testing": "5.3.1"
} }

View File

@ -80,7 +80,7 @@
"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",
@ -160,6 +160,7 @@
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-select": "2.1.0", "react-select": "2.1.0",
"react-sizeme": "^2.3.6", "react-sizeme": "^2.3.6",
"react-table": "^6.8.6",
"react-transition-group": "^2.2.1", "react-transition-group": "^2.2.1",
"redux": "^4.0.0", "redux": "^4.0.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",

View File

@ -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)
@ -251,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
}, reqOrgAdmin) }, reqOrgAdmin)
apiRoute.Get("/frontend/settings/", GetFrontendSettings) apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)

View File

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

View File

@ -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},

View File

@ -55,7 +55,6 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
dsId := c.ParamsInt64(":id") dsId := c.ParamsInt64(":id")
ds, err := hs.getDatasourceFromCache(dsId, c) ds, err := hs.getDatasourceFromCache(dsId, c)
hs.log.Debug("We are in the ds proxy", "dsId", dsId)
if err != nil { if err != nil {
c.JsonApiErr(500, "Unable to load datasource meta data", err) c.JsonApiErr(500, "Unable to load datasource meta data", err)

View File

@ -17,24 +17,8 @@ func GetDataSources(c *m.ReqContext) Response {
return Error(500, "Failed to query datasources", err) return Error(500, "Failed to query datasources", err)
} }
dsFilterQuery := m.DatasourcesPermissionFilterQuery{
User: c.SignedInUser,
Datasources: query.Result,
}
datasources := []*m.DataSource{}
if err := bus.Dispatch(&dsFilterQuery); err != nil {
if err != bus.ErrHandlerNotFound {
return Error(500, "Could not get datasources", err)
}
datasources = query.Result
} else {
datasources = dsFilterQuery.Result
}
result := make(dtos.DataSourceList, 0) result := make(dtos.DataSourceList, 0)
for _, ds := range datasources { for _, ds := range query.Result {
dsItem := dtos.DataSourceListItemDTO{ dsItem := dtos.DataSourceListItemDTO{
OrgId: ds.OrgId, OrgId: ds.OrgId,
Id: ds.Id, Id: ds.Id,

View File

@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string {
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
return &AlertNotification{ return &AlertNotification{
Id: notification.Id, Id: notification.Id,
Name: notification.Name, Name: notification.Name,
Type: notification.Type, Type: notification.Type,
IsDefault: notification.IsDefault, IsDefault: notification.IsDefault,
Created: notification.Created, Created: notification.Created,
Updated: notification.Updated, Updated: notification.Updated,
Frequency: formatShort(notification.Frequency), Frequency: formatShort(notification.Frequency),
SendReminder: notification.SendReminder, SendReminder: notification.SendReminder,
Settings: notification.Settings, DisableResolveMessage: notification.DisableResolveMessage,
Settings: notification.Settings,
} }
} }
type AlertNotification struct { type AlertNotification struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
IsDefault bool `json:"isDefault"` IsDefault bool `json:"isDefault"`
SendReminder bool `json:"sendReminder"` SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"` DisableResolveMessage bool `json:"disableResolveMessage"`
Created time.Time `json:"created"` Frequency string `json:"frequency"`
Updated time.Time `json:"updated"` Created time.Time `json:"created"`
Settings *simplejson.Json `json:"settings"` Updated time.Time `json:"updated"`
Settings *simplejson.Json `json:"settings"`
} }
type AlertTestCommand struct { type AlertTestCommand struct {
@ -100,11 +102,12 @@ type EvalMatch struct {
} }
type NotificationTestCommand struct { 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"`
Frequency string `json:"frequency"` DisableResolveMessage bool `json:"disableResolveMessage"`
Settings *simplejson.Json `json:"settings"` Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings"`
} }
type PauseAlertCommand struct { type PauseAlertCommand struct {

View File

@ -11,7 +11,7 @@ import (
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
orgDataSources := make([]*m.DataSource, 0) orgDataSources := make([]*m.DataSource, 0)
if c.OrgId != 0 { if c.OrgId != 0 {
@ -133,6 +133,10 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
panels := map[string]interface{}{} panels := map[string]interface{}{}
for _, panel := range enabledPlugins.Panels { for _, panel := range enabledPlugins.Panels {
if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels {
continue
}
panels[panel.Id] = map[string]interface{}{ panels[panel.Id] = map[string]interface{}{
"module": panel.Module, "module": panel.Module,
"baseUrl": panel.BaseUrl, "baseUrl": panel.BaseUrl,
@ -196,8 +200,8 @@ func getPanelSort(id string) int {
return sort return sort
} }
func GetFrontendSettings(c *m.ReqContext) { func (hs *HTTPServer) GetFrontendSettings(c *m.ReqContext) {
settings, err := getFrontendSettingsMap(c) settings, err := hs.getFrontendSettingsMap(c)
if err != nil { if err != nil {
c.JsonApiErr(400, "Failed to get frontend settings", err) c.JsonApiErr(400, "Failed to get frontend settings", err)
return return

View File

@ -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() {

View File

@ -17,8 +17,8 @@ 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 := hs.getFrontendSettingsMap(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -316,19 +316,6 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
} }
if c.IsGrafanaAdmin { if c.IsGrafanaAdmin {
children := []*dtos.NavLink{
{Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"},
{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
}
if setting.IsEnterprise {
children = append(children, &dtos.NavLink{Text: "Licensing", Id: "licensing", Url: setting.AppSubUrl + "/admin/licensing", Icon: "fa fa-fw fa-unlock-alt"})
}
children = append(children, &dtos.NavLink{Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"})
cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
Text: "Server Admin", Text: "Server Admin",
HideFromTabs: true, HideFromTabs: true,
@ -336,7 +323,13 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
Id: "admin", Id: "admin",
Icon: "gicon gicon-shield", Icon: "gicon gicon-shield",
Url: setting.AppSubUrl + "/admin/users", Url: setting.AppSubUrl + "/admin/users",
Children: children, Children: []*dtos.NavLink{
{Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"},
{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
{Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"},
},
}) })
} }
@ -357,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
@ -369,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

View File

@ -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

View File

@ -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 + "/")

View File

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

View File

@ -185,7 +185,9 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) { if ldapUser.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole extUser.OrgRoles[group.OrgId] = group.OrgRole
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false {
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
}
} }
} }

View File

@ -23,38 +23,41 @@ var (
) )
type AlertNotification struct { type AlertNotification struct {
Id int64 `json:"id"` Id int64 `json:"id"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
SendReminder bool `json:"sendReminder"` SendReminder bool `json:"sendReminder"`
Frequency time.Duration `json:"frequency"` DisableResolveMessage bool `json:"disableResolveMessage"`
IsDefault bool `json:"isDefault"` Frequency time.Duration `json:"frequency"`
Settings *simplejson.Json `json:"settings"` IsDefault bool `json:"isDefault"`
Created time.Time `json:"created"` Settings *simplejson.Json `json:"settings"`
Updated time.Time `json:"updated"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
} }
type CreateAlertNotificationCommand struct { 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"`
Frequency string `json:"frequency"` DisableResolveMessage bool `json:"disableResolveMessage"`
IsDefault bool `json:"isDefault"` Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings"` IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Result *AlertNotification Result *AlertNotification
} }
type UpdateAlertNotificationCommand struct { type UpdateAlertNotificationCommand struct {
Id int64 `json:"id" binding:"Required"` Id int64 `json:"id" binding:"Required"`
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"`
Frequency string `json:"frequency"` DisableResolveMessage bool `json:"disableResolveMessage"`
IsDefault bool `json:"isDefault"` Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings" binding:"Required"` IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings" binding:"Required"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Result *AlertNotification Result *AlertNotification

View File

@ -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")

View File

@ -195,8 +195,8 @@ type GetDataSourceByNameQuery struct {
type DsPermissionType int type DsPermissionType int
const ( const (
DsPermissionQuery DsPermissionType = 1 << iota DsPermissionNoAccess DsPermissionType = iota
DsPermissionNoAccess DsPermissionQuery
) )
func (p DsPermissionType) String() string { func (p DsPermissionType) String() string {
@ -207,12 +207,6 @@ func (p DsPermissionType) String() string {
return names[int(p)] return names[int(p)]
} }
type HasRequiredDataSourcePermissionQuery struct {
Id int64
User *SignedInUser
RequiredPermission DsPermissionType
}
type GetDataSourcePermissionsForUserQuery struct { type GetDataSourcePermissionsForUserQuery struct {
User *SignedInUser User *SignedInUser
Result map[int64]DsPermissionType Result map[int64]DsPermissionType

View File

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

View File

@ -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)

View File

@ -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")
}) })
}) })
}) })

View File

@ -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
} }

View File

@ -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"
) )
@ -15,13 +14,14 @@ const (
) )
type NotifierBase struct { type NotifierBase struct {
Name string Name string
Type string Type string
Id int64 Id int64
IsDeault bool IsDeault bool
UploadImage bool UploadImage bool
SendReminder bool SendReminder bool
Frequency time.Duration DisableResolveMessage bool
Frequency time.Duration
log log.Logger log log.Logger
} }
@ -34,14 +34,15 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
} }
return NotifierBase{ return NotifierBase{
Id: model.Id, Id: model.Id,
Name: model.Name, Name: model.Name,
IsDeault: model.IsDefault, IsDeault: model.IsDefault,
Type: model.Type, Type: model.Type,
UploadImage: uploadImage, UploadImage: uploadImage,
SendReminder: model.SendReminder, SendReminder: model.SendReminder,
Frequency: model.Frequency, DisableResolveMessage: model.DisableResolveMessage,
log: log.New("alerting.notifier." + model.Name), Frequency: model.Frequency,
log: log.New("alerting.notifier." + model.Name),
} }
} }
@ -83,6 +84,11 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC
} }
} }
// Do not notify when state is OK if DisableResolveMessage is set to true
if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage {
return false
}
return true 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
} }

View File

@ -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)
})
}) })
} }

View File

@ -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
} }

View File

@ -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

View File

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

View File

@ -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
} }
} }

View File

@ -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")
}) })
}) })

View 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)
}
}

View File

@ -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
@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
} }
alertNotification := &m.AlertNotification{ alertNotification := &m.AlertNotification{
OrgId: cmd.OrgId, OrgId: cmd.OrgId,
Name: cmd.Name, Name: cmd.Name,
Type: cmd.Type, Type: cmd.Type,
Settings: cmd.Settings, Settings: cmd.Settings,
SendReminder: cmd.SendReminder, SendReminder: cmd.SendReminder,
Frequency: frequency, DisableResolveMessage: cmd.DisableResolveMessage,
Created: time.Now(), Frequency: frequency,
Updated: time.Now(), Created: time.Now(),
IsDefault: cmd.IsDefault, Updated: time.Now(),
IsDefault: cmd.IsDefault,
} }
if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil { if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil {
@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Type = cmd.Type current.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

View File

@ -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)
@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Can update alert notification", func() { Convey("Can update alert notification", func() {
newCmd := &models.UpdateAlertNotificationCommand{ newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName", Name: "NewName",
Type: "webhook", Type: "webhook",
OrgId: cmd.Result.OrgId, OrgId: cmd.Result.OrgId,
SendReminder: true, SendReminder: true,
Frequency: "60s", DisableResolveMessage: true,
Settings: simplejson.New(), Frequency: "60s",
Id: cmd.Result.Id, Settings: simplejson.New(),
Id: cmd.Result.Id,
} }
err := UpdateAlertNotification(newCmd) err := UpdateAlertNotification(newCmd)
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() {

View File

@ -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]))

View File

@ -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() {

View File

@ -213,6 +213,8 @@ type Cfg struct {
TempDataLifetime time.Duration TempDataLifetime time.Duration
MetricsEndpointEnabled bool MetricsEndpointEnabled bool
EnableAlphaPanels bool
} }
type CommandLineArgs struct { type CommandLineArgs struct {
@ -694,6 +696,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
explore := iniFile.Section("explore") explore := iniFile.Section("explore")
ExploreEnabled = explore.Key("enabled").MustBool(false) ExploreEnabled = explore.Key("enabled").MustBool(false)
panels := iniFile.Section("panels")
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
cfg.readSessionConfig() cfg.readSessionConfig()
cfg.readSmtpSettings() cfg.readSmtpSettings()
cfg.readQuotaSettings() cfg.readQuotaSettings()

View File

@ -86,9 +86,10 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
} }
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
result := &tsdb.Response{ results := &tsdb.Response{
Results: make(map[string]*tsdb.QueryResult), Results: make(map[string]*tsdb.QueryResult),
} }
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
eg, ectx := errgroup.WithContext(ctx) eg, ectx := errgroup.WithContext(ctx)
@ -102,10 +103,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
RefId := queryContext.Queries[i].RefId RefId := queryContext.Queries[i].RefId
query, err := parseQuery(queryContext.Queries[i].Model) query, err := parseQuery(queryContext.Queries[i].Model)
if err != nil { if err != nil {
result.Results[RefId] = &tsdb.QueryResult{ results.Results[RefId] = &tsdb.QueryResult{
Error: err, Error: err,
} }
return result, nil return results, nil
} }
query.RefId = RefId query.RefId = RefId
@ -118,10 +119,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
} }
if query.Id == "" && query.Expression != "" { if query.Id == "" && query.Expression != "" {
result.Results[query.RefId] = &tsdb.QueryResult{ results.Results[query.RefId] = &tsdb.QueryResult{
Error: fmt.Errorf("Invalid query: id should be set if using expression"), Error: fmt.Errorf("Invalid query: id should be set if using expression"),
} }
return result, nil return results, nil
} }
eg.Go(func() error { eg.Go(func() error {
@ -130,12 +131,13 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
return err return err
} }
if err != nil { if err != nil {
result.Results[query.RefId] = &tsdb.QueryResult{ resultChan <- &tsdb.QueryResult{
RefId: query.RefId,
Error: err, Error: err,
} }
return nil return nil
} }
result.Results[queryRes.RefId] = queryRes resultChan <- queryRes
return nil return nil
}) })
} }
@ -149,10 +151,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
return err return err
} }
for _, queryRes := range queryResponses { for _, queryRes := range queryResponses {
result.Results[queryRes.RefId] = queryRes
if err != nil { if err != nil {
result.Results[queryRes.RefId].Error = err queryRes.Error = err
} }
resultChan <- queryRes
} }
return nil return nil
}) })
@ -162,8 +164,12 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
return nil, err return nil, err
} }
close(resultChan)
for result := range resultChan {
results.Results[result.RefId] = result
}
return result, nil return results, nil
} }
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {

View File

@ -234,10 +234,37 @@ func parseMultiSelectValue(input string) []string {
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
regions := []string{ regions := []string{
"ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1",
"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", "us-isob-east-1", "us-iso-east-1", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2",
"cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1",
} }
err := e.ensureClientSession("us-east-1")
if err != nil {
return nil, err
}
r, err := e.ec2Svc.DescribeRegions(&ec2.DescribeRegionsInput{})
if err != nil {
// ignore error for backward compatibility
plog.Error("Failed to get regions", "error", err)
} else {
for _, region := range r.Regions {
exists := false
for _, existingRegion := range regions {
if existingRegion == *region.RegionName {
exists = true
break
}
}
if !exists {
regions = append(regions, *region.RegionName)
}
}
}
sort.Strings(regions)
result := make([]suggestData, 0) result := make([]suggestData, 0)
for _, region := range regions { for _, region := range regions {
result = append(result, suggestData{Text: region, Value: region}) result = append(result, suggestData{Text: region, Value: region})

View File

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

View File

@ -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)

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"strconv" "strconv"
"strings"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"github.com/go-xorm/core" "github.com/go-xorm/core"
@ -20,10 +21,14 @@ func init() {
func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
logger := log.New("tsdb.mysql") logger := log.New("tsdb.mysql")
protocol := "tcp"
if strings.HasPrefix(datasource.Url, "/") {
protocol = "unix"
}
cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true", cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true",
datasource.User, datasource.User,
datasource.Password, datasource.Password,
"tcp", protocol,
datasource.Url, datasource.Url,
datasource.Database, datasource.Database,
) )

View File

@ -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)

View File

@ -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)

View File

@ -26,8 +26,12 @@ _.move = (array, fromIndex, toIndex) => {
return array; return array;
}; };
import { coreModule, registerAngularDirectives } from './core/core'; import { coreModule, angularModules } from 'app/core/core_module';
import { setupAngularRoutes } from './routes/routes'; import { registerAngularDirectives } from 'app/core/core';
import { setupAngularRoutes } from 'app/routes/routes';
import 'app/routes/GrafanaCtrl';
import 'app/features/all';
// import symlinked extensions // import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
@ -109,39 +113,26 @@ export class GrafanaApp {
'react', 'react',
]; ];
const moduleTypes = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
_.each(moduleTypes, type => {
const moduleName = 'grafana.' + type;
this.useModule(angular.module(moduleName, []));
});
// makes it possible to add dynamic stuff // makes it possible to add dynamic stuff
this.useModule(coreModule); _.each(angularModules, m => {
this.useModule(m);
});
// register react angular wrappers // register react angular wrappers
coreModule.config(setupAngularRoutes); coreModule.config(setupAngularRoutes);
registerAngularDirectives(); registerAngularDirectives();
const preBootRequires = [import('app/features/all')]; // disable tool tip animation
$.fn.tooltip.defaults.animation = false;
Promise.all(preBootRequires) // bootstrap the app
.then(() => { angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
// disable tool tip animation _.each(this.preBootModules, module => {
$.fn.tooltip.defaults.animation = false; _.extend(module, this.registerFunctions);
// bootstrap the app
angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
_.each(this.preBootModules, module => {
_.extend(module, this.registerFunctions);
});
this.preBootModules = null;
});
})
.catch(err => {
console.log('Application boot failed:', err);
}); });
this.preBootModules = null;
});
} }
} }

View File

@ -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',

View File

@ -103,7 +103,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
$scope.$apply(() => { $scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => { $scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => {
const dynamicOptions = _.map(result, op => { const dynamicOptions = _.map(result, op => {
return op.value; return _.escape(op.value);
}); });
callback(dynamicOptions); callback(dynamicOptions);
}); });
@ -117,6 +117,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
minLength: 0, minLength: 0,
items: 1000, items: 1000,
updater: value => { updater: value => {
value = _.unescape(value);
setTimeout(() => { setTimeout(() => {
inputBlur.call($input[0], paramIndex); inputBlur.call($input[0], paramIndex);
}, 0); }, 0);

View File

@ -18,6 +18,7 @@ export function geminiScrollbar() {
let scrollRoot = elem.parent(); let scrollRoot = elem.parent();
const scroller = elem; const scroller = elem;
console.log('scroll');
if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') { if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') {
scrollRoot = scroller; scrollRoot = scroller;
} }

View File

@ -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" />

View File

@ -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>,

View File

@ -109,12 +109,12 @@ export function sqlPartEditorDirective($compile, templateSrv) {
$scope.$apply(() => { $scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(result => { $scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(result => {
const dynamicOptions = _.map(result, op => { const dynamicOptions = _.map(result, op => {
return op.value; return _.escape(op.value);
}); });
// add current value to dropdown if it's not in dynamicOptions // add current value to dropdown if it's not in dynamicOptions
if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) { if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) {
dynamicOptions.unshift(part.params[paramIndex]); dynamicOptions.unshift(_.escape(part.params[paramIndex]));
} }
callback(dynamicOptions); callback(dynamicOptions);
@ -129,6 +129,7 @@ export function sqlPartEditorDirective($compile, templateSrv) {
minLength: 0, minLength: 0,
items: 1000, items: 1000,
updater: value => { updater: value => {
value = _.unescape(value);
if (value === part.params[paramIndex]) { if (value === part.params[paramIndex]) {
clearTimeout(cancelBlur); clearTimeout(cancelBlur);
$input.focus(); $input.focus();

View File

@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import { PanelPlugin } from 'app/types/plugins';
export interface BuildInfo { export interface BuildInfo {
version: string; version: string;
@ -9,7 +10,7 @@ export interface BuildInfo {
export class Settings { export class Settings {
datasources: any; datasources: any;
panels: any; panels: PanelPlugin[];
appSubUrl: string; appSubUrl: string;
windowTitlePrefix: string; windowTitlePrefix: string;
buildInfo: BuildInfo; buildInfo: BuildInfo;

View File

@ -8,3 +8,6 @@ export const DEFAULT_ROW_HEIGHT = 250;
export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
export const LS_PANEL_COPY_KEY = 'panel-copy'; export const LS_PANEL_COPY_KEY = 'panel-copy';
export const DASHBOARD_TOOLBAR_HEIGHT = 55;
export const DASHBOARD_TOP_PADDING = 20;

View File

@ -19,7 +19,6 @@ import './components/colorpicker/spectrum_picker';
import './services/search_srv'; import './services/search_srv';
import './services/ng_react'; import './services/ng_react';
import { grafanaAppDirective } from './components/grafana_app';
import { searchDirective } from './components/search/search'; import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover'; import { infoPopover } from './components/info_popover';
import { navbarDirective } from './components/navbar/navbar'; import { navbarDirective } from './components/navbar/navbar';
@ -60,7 +59,6 @@ export {
registerAngularDirectives, registerAngularDirectives,
arrayJoin, arrayJoin,
coreModule, coreModule,
grafanaAppDirective,
navbarDirective, navbarDirective,
searchDirective, searchDirective,
liveSrv, liveSrv,

View File

@ -1,2 +1,18 @@
import angular from 'angular'; import angular from 'angular';
export default angular.module('grafana.core', ['ngRoute']);
const coreModule = angular.module('grafana.core', ['ngRoute']);
// legacy modules
const angularModules = [
coreModule,
angular.module('grafana.controllers', []),
angular.module('grafana.directives', []),
angular.module('grafana.factories', []),
angular.module('grafana.services', []),
angular.module('grafana.filters', []),
angular.module('grafana.routes', []),
];
export { angularModules, coreModule };
export default coreModule;

View File

@ -2,16 +2,21 @@ import _ from 'lodash';
import coreModule from '../core_module'; import coreModule from '../core_module';
/** @ngInject */ /** @ngInject */
export function dashClass() { function dashClass($timeout) {
return { return {
link: ($scope, elem) => { link: ($scope, elem) => {
$scope.onAppEvent('panel-fullscreen-enter', () => { $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
elem.toggleClass('panel-in-fullscreen', true); console.log('view-mode-changed', panel.fullscreen);
if (panel.fullscreen) {
elem.addClass('panel-in-fullscreen');
} else {
$timeout(() => {
elem.removeClass('panel-in-fullscreen');
});
}
}); });
$scope.onAppEvent('panel-fullscreen-exit', () => { elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
elem.toggleClass('panel-in-fullscreen', false);
});
$scope.$watch('ctrl.dashboardViewState.state.editview', newValue => { $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
if (newValue) { if (newValue) {

View File

@ -3,7 +3,7 @@ import $ from 'jquery';
import coreModule from '../core_module'; import coreModule from '../core_module';
/** @ngInject */ /** @ngInject */
export function metricSegment($compile, $sce) { export function metricSegment($compile, $sce, templateSrv) {
const inputTemplate = const inputTemplate =
'<input type="text" data-provide="typeahead" ' + '<input type="text" data-provide="typeahead" ' +
' class="gf-form-input input-medium"' + ' class="gf-form-input input-medium"' +
@ -41,13 +41,11 @@ export function metricSegment($compile, $sce) {
return; return;
} }
value = _.unescape(value);
$scope.$apply(() => { $scope.$apply(() => {
const selected = _.find($scope.altSegments, { value: value }); const selected = _.find($scope.altSegments, { value: value });
if (selected) { if (selected) {
segment.value = selected.value; segment.value = selected.value;
segment.html = selected.html || selected.value; segment.html = selected.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(selected.value));
segment.fake = false; segment.fake = false;
segment.expandable = selected.expandable; segment.expandable = selected.expandable;
@ -56,7 +54,7 @@ export function metricSegment($compile, $sce) {
} }
} else if (segment.custom !== 'false') { } else if (segment.custom !== 'false') {
segment.value = value; segment.value = value;
segment.html = $sce.trustAsHtml(value); segment.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(value));
segment.expandable = true; segment.expandable = true;
segment.fake = false; segment.fake = false;
} }
@ -95,7 +93,7 @@ export function metricSegment($compile, $sce) {
// add custom values // add custom values
if (segment.custom !== 'false') { if (segment.custom !== 'false') {
if (!segment.fake && _.indexOf(options, segment.value) === -1) { if (!segment.fake && _.indexOf(options, segment.value) === -1) {
options.unshift(segment.value); options.unshift(_.escape(segment.value));
} }
} }
@ -105,6 +103,7 @@ export function metricSegment($compile, $sce) {
}; };
$scope.updater = value => { $scope.updater = value => {
value = _.unescape(value);
if (value === segment.value) { if (value === segment.value) {
clearTimeout(cancelBlur); clearTimeout(cancelBlur);
$input.focus(); $input.focus();

View File

@ -1,6 +1,7 @@
import { Action } from 'app/core/actions/location'; import { Action } from 'app/core/actions/location';
import { LocationState } from 'app/types'; import { LocationState } from 'app/types';
import { renderUrl } from 'app/core/utils/url'; import { renderUrl } from 'app/core/utils/url';
import _ from 'lodash';
export const initialState: LocationState = { export const initialState: LocationState = {
url: '', url: '',
@ -12,11 +13,17 @@ export const initialState: LocationState = {
export const locationReducer = (state = initialState, action: Action): LocationState => { export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) { switch (action.type) {
case 'UPDATE_LOCATION': { case 'UPDATE_LOCATION': {
const { path, query, routeParams } = action.payload; const { path, routeParams } = action.payload;
let query = action.payload.query || state.query;
if (action.payload.partial) {
query = _.defaults(query, state.query);
}
return { return {
url: renderUrl(path || state.path, query), url: renderUrl(path || state.path, query),
path: path || state.path, path: path || state.path,
query: query || state.query, query: query,
routeParams: routeParams || state.routeParams, routeParams: routeParams || state.routeParams,
}; };
} }

View File

@ -3,7 +3,7 @@ import coreModule from '../core_module';
class DynamicDirectiveSrv { class DynamicDirectiveSrv {
/** @ngInject */ /** @ngInject */
constructor(private $compile, private $rootScope) {} constructor(private $compile) {}
addDirective(element, name, scope) { addDirective(element, name, scope) {
const child = angular.element(document.createElement(name)); const child = angular.element(document.createElement(name));
@ -14,25 +14,19 @@ class DynamicDirectiveSrv {
} }
link(scope, elem, attrs, options) { link(scope, elem, attrs, options) {
options const directiveInfo = options.directive(scope);
.directive(scope) if (!directiveInfo || !directiveInfo.fn) {
.then(directiveInfo => { elem.empty();
if (!directiveInfo || !directiveInfo.fn) { return;
elem.empty(); }
return;
}
if (!directiveInfo.fn.registered) { if (!directiveInfo.fn.registered) {
coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn); console.log('register panel tab');
directiveInfo.fn.registered = true; coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn);
} directiveInfo.fn.registered = true;
}
this.addDirective(elem, directiveInfo.name, scope); this.addDirective(elem, directiveInfo.name, scope);
})
.catch(err => {
console.log('Plugin load:', err);
this.$rootScope.appEvent('alert-error', ['Plugin error', err.toString()]);
});
} }
create(options) { create(options) {

View File

@ -148,7 +148,7 @@ export class KeybindingSrv {
this.bind('mod+o', () => { this.bind('mod+o', () => {
dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
appEvents.emit('graph-hover-clear'); appEvents.emit('graph-hover-clear');
this.$rootScope.$broadcast('refresh'); dashboard.startRefresh();
}); });
this.bind('mod+s', e => { this.bind('mod+s', e => {
@ -257,7 +257,7 @@ export class KeybindingSrv {
}); });
this.bind('d r', () => { this.bind('d r', () => {
this.$rootScope.$broadcast('refresh'); dashboard.startRefresh();
}); });
this.bind('d s', () => { this.bind('d s', () => {

View File

@ -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);

View File

@ -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");
});
}); });
}); });

View File

@ -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);
} }

View File

@ -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' },
], ],
}, },
{ {

View File

@ -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',

View File

@ -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>

View File

@ -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;
} }

View File

@ -22,7 +22,6 @@ import './export_data/export_data_modal';
import './ad_hoc_filters'; import './ad_hoc_filters';
import './repeat_option/repeat_option'; import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective'; import './dashgrid/DashboardGridDirective';
import './dashgrid/PanelLoader';
import './dashgrid/RowOptions'; import './dashgrid/RowOptions';
import './folder_picker/folder_picker'; import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder'; import './move_to_folder_modal/move_to_folder';

View File

@ -1,11 +1,10 @@
import config from 'app/core/config'; import config from 'app/core/config';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { PanelContainer } from './dashgrid/PanelContainer';
import { DashboardModel } from './dashboard_model'; import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model'; import { PanelModel } from './panel_model';
export class DashboardCtrl implements PanelContainer { export class DashboardCtrl {
dashboard: DashboardModel; dashboard: DashboardModel;
dashboardViewState: any; dashboardViewState: any;
loadedFallbackDashboard: boolean; loadedFallbackDashboard: boolean;
@ -22,8 +21,7 @@ export class DashboardCtrl implements PanelContainer {
private dashboardSrv, private dashboardSrv,
private unsavedChangesSrv, private unsavedChangesSrv,
private dashboardViewStateSrv, private dashboardViewStateSrv,
public playlistSrv, public playlistSrv
private panelLoader
) { ) {
// temp hack due to way dashboards are loaded // temp hack due to way dashboards are loaded
// can't use controllerAs on route yet // can't use controllerAs on route yet
@ -119,14 +117,6 @@ export class DashboardCtrl implements PanelContainer {
return this.dashboard; return this.dashboard;
} }
getPanelLoader() {
return this.panelLoader;
}
timezoneChanged() {
this.$rootScope.$broadcast('refresh');
}
getPanelContainer() { getPanelContainer() {
return this; return this;
} }
@ -168,10 +158,17 @@ export class DashboardCtrl implements PanelContainer {
this.dashboard.removePanel(panel); this.dashboard.removePanel(panel);
} }
onDestroy() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
init(dashboard) { init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this)); this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
this.$scope.$on('$destroy', this.onDestroy.bind(this));
this.setupDashboard(dashboard); this.setupDashboard(dashboard);
} }
} }

View File

@ -200,6 +200,43 @@ export class DashboardModel {
this.events.emit('view-mode-changed', panel); this.events.emit('view-mode-changed', panel);
} }
timeRangeUpdated() {
this.events.emit('time-range-updated');
}
startRefresh() {
this.events.emit('refresh');
for (const panel of this.panels) {
if (!this.otherPanelInFullscreen(panel)) {
panel.refresh();
}
}
}
render() {
this.events.emit('render');
for (const panel of this.panels) {
panel.render();
}
}
panelInitialized(panel: PanelModel) {
if (!this.otherPanelInFullscreen(panel)) {
panel.refresh();
}
}
otherPanelInFullscreen(panel: PanelModel) {
return this.meta.fullscreen && !panel.fullscreen;
}
changePanelType(panel: PanelModel, pluginId: string) {
panel.changeType(pluginId);
this.events.emit('panel-type-changed', panel);
}
private ensureListExist(data) { private ensureListExist(data) {
if (!data) { if (!data) {
data = {}; data = {};

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import config from 'app/core/config'; import config from 'app/core/config';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';
import { PanelContainer } from './PanelContainer'; import { DashboardModel } from '../dashboard_model';
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar'; import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
import store from 'app/core/store'; import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { LS_PANEL_COPY_KEY } from 'app/core/constants';
@ -11,7 +11,7 @@ import Highlighter from 'react-highlight-words';
export interface AddPanelPanelProps { export interface AddPanelPanelProps {
panel: PanelModel; panel: PanelModel;
getPanelContainer: () => PanelContainer; dashboard: DashboardModel;
} }
export interface AddPanelPanelState { export interface AddPanelPanelState {
@ -93,8 +93,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
} }
onAddPanel = panelPluginInfo => { onAddPanel = panelPluginInfo => {
const panelContainer = this.props.getPanelContainer(); const dashboard = this.props.dashboard;
const dashboard = panelContainer.getDashboard();
const { gridPos } = this.props.panel; const { gridPos } = this.props.panel;
const newPanel: any = { const newPanel: any = {
@ -123,9 +122,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
handleCloseAddPanel(evt) { handleCloseAddPanel(evt) {
evt.preventDefault(); evt.preventDefault();
const panelContainer = this.props.getPanelContainer(); this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
const dashboard = panelContainer.getDashboard();
dashboard.removePanel(dashboard.panels[0]);
} }
renderText(text: string) { renderText(text: string) {

View File

@ -3,7 +3,6 @@ import ReactGridLayout from 'react-grid-layout';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel'; import { DashboardPanel } from './DashboardPanel';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../dashboard_model';
import { PanelContainer } from './PanelContainer';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';
import classNames from 'classnames'; import classNames from 'classnames';
import sizeMe from 'react-sizeme'; import sizeMe from 'react-sizeme';
@ -60,18 +59,15 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps { export interface DashboardGridProps {
getPanelContainer: () => PanelContainer; dashboard: DashboardModel;
} }
export class DashboardGrid extends React.Component<DashboardGridProps, any> { export class DashboardGrid extends React.Component<DashboardGridProps, any> {
gridToPanelMap: any; gridToPanelMap: any;
panelContainer: PanelContainer;
dashboard: DashboardModel;
panelMap: { [id: string]: PanelModel }; panelMap: { [id: string]: PanelModel };
constructor(props) { constructor(props) {
super(props); super(props);
this.panelContainer = this.props.getPanelContainer();
this.onLayoutChange = this.onLayoutChange.bind(this); this.onLayoutChange = this.onLayoutChange.bind(this);
this.onResize = this.onResize.bind(this); this.onResize = this.onResize.bind(this);
this.onResizeStop = this.onResizeStop.bind(this); this.onResizeStop = this.onResizeStop.bind(this);
@ -81,20 +77,21 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.state = { animated: false }; this.state = { animated: false };
// subscribe to dashboard events // subscribe to dashboard events
this.dashboard = this.panelContainer.getDashboard(); const dashboard = this.props.dashboard;
this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this)); dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this)); dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this)); dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this)); dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this));
this.dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this)); dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
this.dashboard.on('row-expanded', this.triggerForceUpdate.bind(this)); dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
dashboard.on('panel-type-changed', this.triggerForceUpdate.bind(this));
} }
buildLayout() { buildLayout() {
const layout = []; const layout = [];
this.panelMap = {}; this.panelMap = {};
for (const panel of this.dashboard.panels) { for (const panel of this.props.dashboard.panels) {
const stringId = panel.id.toString(); const stringId = panel.id.toString();
this.panelMap[stringId] = panel; this.panelMap[stringId] = panel;
@ -129,7 +126,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.panelMap[newPos.i].updateGridPos(newPos); this.panelMap[newPos.i].updateGridPos(newPos);
} }
this.dashboard.sortPanelsByGridPos(); this.props.dashboard.sortPanelsByGridPos();
} }
triggerForceUpdate() { triggerForceUpdate() {
@ -137,11 +134,15 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
} }
onWidthChange() { onWidthChange() {
for (const panel of this.dashboard.panels) { for (const panel of this.props.dashboard.panels) {
panel.resizeDone(); panel.resizeDone();
} }
} }
onViewModeChanged(payload) {
this.setState({ animated: !payload.fullscreen });
}
updateGridPos(item, layout) { updateGridPos(item, layout) {
this.panelMap[item.i].updateGridPos(item); this.panelMap[item.i].updateGridPos(item);
@ -165,21 +166,18 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
componentDidMount() { componentDidMount() {
setTimeout(() => { setTimeout(() => {
this.setState(() => { this.setState({ animated: true });
return { animated: true };
});
}); });
} }
renderPanels() { renderPanels() {
const panelElements = []; const panelElements = [];
for (const panel of this.dashboard.panels) { for (const panel of this.props.dashboard.panels) {
const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen }); const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
panelElements.push( panelElements.push(
/** panel-id is set for html bookmarks */ <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
<div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id.toString()}`}> <DashboardPanel panel={panel} dashboard={this.props.dashboard} panelType={panel.type} />
<DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
</div> </div>
); );
} }
@ -192,8 +190,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
<SizedReactLayoutGrid <SizedReactLayoutGrid
className={classNames({ layout: true, animated: this.state.animated })} className={classNames({ layout: true, animated: this.state.animated })}
layout={this.buildLayout()} layout={this.buildLayout()}
isResizable={this.dashboard.meta.canEdit} isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.dashboard.meta.canEdit} isDraggable={this.props.dashboard.meta.canEdit}
onLayoutChange={this.onLayoutChange} onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange} onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop} onDragStop={this.onDragStop}

View File

@ -1,6 +1,4 @@
import { react2AngularDirective } from 'app/core/utils/react2angular'; import { react2AngularDirective } from 'app/core/utils/react2angular';
import { DashboardGrid } from './DashboardGrid'; import { DashboardGrid } from './DashboardGrid';
react2AngularDirective('dashboardGrid', DashboardGrid, [ react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);
['getPanelContainer', { watchDepth: 'reference', wrapApply: false }],
]);

View File

@ -1,54 +1,161 @@
import React from 'react'; import React from 'react';
import {PanelModel} from '../panel_model'; import config from 'app/core/config';
import {PanelContainer} from './PanelContainer'; import { PanelModel } from '../panel_model';
import {AttachedPanel} from './PanelLoader'; import { DashboardModel } from '../dashboard_model';
import {DashboardRow} from './DashboardRow'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import {AddPanelPanel} from './AddPanelPanel'; import { DashboardRow } from './DashboardRow';
import { AddPanelPanel } from './AddPanelPanel';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
import { PluginExports, PanelPlugin } from 'app/types/plugins';
import { PanelChrome } from './PanelChrome';
import { PanelEditor } from './PanelEditor';
export interface DashboardPanelProps { export interface Props {
panelType: string;
panel: PanelModel; panel: PanelModel;
getPanelContainer: () => PanelContainer; dashboard: DashboardModel;
} }
export class DashboardPanel extends React.Component<DashboardPanelProps, any> { export interface State {
pluginExports: PluginExports;
}
export class DashboardPanel extends React.Component<Props, State> {
element: any; element: any;
attachedPanel: AttachedPanel; angularPanel: AngularComponent;
pluginInfo: any;
specialPanels = {};
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {};
this.state = {
pluginExports: null,
};
this.specialPanels['row'] = this.renderRow.bind(this);
this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
} }
componentDidMount() { isSpecial() {
if (!this.element) { return this.specialPanels[this.props.panel.type];
}
renderRow() {
return <DashboardRow panel={this.props.panel} dashboard={this.props.dashboard} />;
}
renderAddPanel() {
return <AddPanelPanel panel={this.props.panel} dashboard={this.props.dashboard} />;
}
onPluginTypeChanged = (plugin: PanelPlugin) => {
this.props.panel.changeType(plugin.id);
this.loadPlugin();
};
onAngularPluginTypeChanged = () => {
this.loadPlugin();
};
loadPlugin() {
if (this.isSpecial()) {
return; return;
} }
const panelContainer = this.props.getPanelContainer(); // handle plugin loading & changing of plugin type
const dashboard = panelContainer.getDashboard(); if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
const loader = panelContainer.getPanelLoader(); this.pluginInfo = config.panels[this.props.panel.type];
this.attachedPanel = loader.load(this.element, this.props.panel, dashboard);
if (this.pluginInfo.exports) {
this.cleanUpAngularPanel();
this.setState({ pluginExports: this.pluginInfo.exports });
} else {
importPluginModule(this.pluginInfo.module).then(pluginExports => {
this.cleanUpAngularPanel();
// cache plugin exports (saves a promise async cycle next time)
this.pluginInfo.exports = pluginExports;
// update panel state
this.setState({ pluginExports: pluginExports });
});
}
}
}
componentDidMount() {
this.loadPlugin();
}
componentDidUpdate() {
this.loadPlugin();
// handle angular plugin loading
if (!this.element || this.angularPanel) {
return;
}
const loader = getAngularLoader();
const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
this.angularPanel = loader.load(this.element, scopeProps, template);
}
cleanUpAngularPanel() {
if (this.angularPanel) {
this.angularPanel.destroy();
this.angularPanel = null;
}
} }
componentWillUnmount() { componentWillUnmount() {
if (this.attachedPanel) { this.cleanUpAngularPanel();
this.attachedPanel.destroy(); }
}
renderReactPanel() {
const { pluginExports } = this.state;
const containerClass = this.props.panel.isEditing ? 'panel-editor-container' : 'panel-height-helper';
const panelWrapperClass = this.props.panel.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
// this might look strange with these classes that change when edit, but
// I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
return (
<div className={containerClass}>
<div className={panelWrapperClass}>
<PanelChrome
component={pluginExports.PanelComponent}
panel={this.props.panel}
dashboard={this.props.dashboard}
/>
</div>
{this.props.panel.isEditing && (
<div className="panel-editor-container__editor">
<PanelEditor
panel={this.props.panel}
panelType={this.props.panel.type}
dashboard={this.props.dashboard}
onTypeChanged={this.onPluginTypeChanged}
pluginExports={pluginExports}
/>
</div>
)}
</div>
);
} }
render() { render() {
// special handling for rows if (this.isSpecial()) {
if (this.props.panel.type === 'row') { return this.specialPanels[this.props.panel.type]();
return <DashboardRow panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />;
} }
if (this.props.panel.type === 'add-panel') { if (!this.state.pluginExports) {
return <AddPanelPanel panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />; return null;
} }
return ( if (this.state.pluginExports.PanelComponent) {
<div ref={element => this.element = element} className="panel-height-helper" /> return this.renderReactPanel();
); }
// legacy angular rendering
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
} }
} }

View File

@ -1,19 +1,16 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';
import { PanelContainer } from './PanelContainer'; import { DashboardModel } from '../dashboard_model';
import templateSrv from 'app/features/templating/template_srv'; import templateSrv from 'app/features/templating/template_srv';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
export interface DashboardRowProps { export interface DashboardRowProps {
panel: PanelModel; panel: PanelModel;
getPanelContainer: () => PanelContainer; dashboard: DashboardModel;
} }
export class DashboardRow extends React.Component<DashboardRowProps, any> { export class DashboardRow extends React.Component<DashboardRowProps, any> {
dashboard: any;
panelContainer: any;
constructor(props) { constructor(props) {
super(props); super(props);
@ -21,9 +18,6 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed, collapsed: this.props.panel.collapsed,
}; };
this.panelContainer = this.props.getPanelContainer();
this.dashboard = this.panelContainer.getDashboard();
this.toggle = this.toggle.bind(this); this.toggle = this.toggle.bind(this);
this.openSettings = this.openSettings.bind(this); this.openSettings = this.openSettings.bind(this);
this.delete = this.delete.bind(this); this.delete = this.delete.bind(this);
@ -31,7 +25,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
} }
toggle() { toggle() {
this.dashboard.toggleRow(this.props.panel); this.props.dashboard.toggleRow(this.props.panel);
this.setState(prevState => { this.setState(prevState => {
return { collapsed: !prevState.collapsed }; return { collapsed: !prevState.collapsed };
@ -39,7 +33,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
} }
update() { update() {
this.dashboard.processRepeats(); this.props.dashboard.processRepeats();
this.forceUpdate(); this.forceUpdate();
} }
@ -61,14 +55,10 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
altActionText: 'Delete row only', altActionText: 'Delete row only',
icon: 'fa-trash', icon: 'fa-trash',
onConfirm: () => { onConfirm: () => {
const panelContainer = this.props.getPanelContainer(); this.props.dashboard.removeRow(this.props.panel, true);
const dashboard = panelContainer.getDashboard();
dashboard.removeRow(this.props.panel, true);
}, },
onAltAction: () => { onAltAction: () => {
const panelContainer = this.props.getPanelContainer(); this.props.dashboard.removeRow(this.props.panel, false);
const dashboard = panelContainer.getDashboard();
dashboard.removeRow(this.props.panel, false);
}, },
}); });
} }
@ -87,7 +77,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars); const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
const count = this.props.panel.panels ? this.props.panel.panels.length : 0; const count = this.props.panel.panels ? this.props.panel.panels.length : 0;
const panels = count === 1 ? 'panel' : 'panels'; const panels = count === 1 ? 'panel' : 'panels';
const canEdit = this.dashboard.meta.canEdit === true; const canEdit = this.props.dashboard.meta.canEdit === true;
return ( return (
<div className={classes}> <div className={classes}>

View File

@ -0,0 +1,151 @@
// Library
import React, { Component } from 'react';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Types
import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
interface RenderProps {
loading: LoadingState;
timeSeries: TimeSeries[];
}
export interface Props {
datasource: string | null;
queries: any[];
panelId?: number;
dashboardId?: number;
isVisible?: boolean;
timeRange?: TimeRange;
refreshCounter: number;
children: (r: RenderProps) => JSX.Element;
}
export interface State {
isFirstLoad: boolean;
loading: LoadingState;
response: DataQueryResponse;
}
export class DataPanel extends Component<Props, State> {
static defaultProps = {
isVisible: true,
panelId: 1,
dashboardId: 1,
};
constructor(props: Props) {
super(props);
this.state = {
loading: LoadingState.NotStarted,
response: {
data: [],
},
isFirstLoad: true,
};
}
componentDidMount() {
console.log('DataPanel mount');
}
async componentDidUpdate(prevProps: Props) {
if (!this.hasPropsChanged(prevProps)) {
return;
}
this.issueQueries();
}
hasPropsChanged(prevProps: Props) {
return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
}
issueQueries = async () => {
const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
if (!isVisible) {
return;
}
if (!queries.length) {
this.setState({ loading: LoadingState.Done });
return;
}
this.setState({ loading: LoadingState.Loading });
try {
const dataSourceSrv = getDatasourceSrv();
const ds = await dataSourceSrv.get(datasource);
const queryOptions: DataQueryOptions = {
timezone: 'browser',
panelId: panelId,
dashboardId: dashboardId,
range: timeRange,
rangeRaw: timeRange.raw,
interval: '1s',
intervalMs: 60000,
targets: queries,
maxDataPoints: 500,
scopedVars: {},
cacheTimeout: null,
};
console.log('Issuing DataPanel query', queryOptions);
const resp = await ds.query(queryOptions);
console.log('Issuing DataPanel query Resp', resp);
this.setState({
loading: LoadingState.Done,
response: resp,
isFirstLoad: false,
});
} catch (err) {
console.log('Loading error', err);
this.setState({ loading: LoadingState.Error, isFirstLoad: false });
}
};
render() {
const { response, loading, isFirstLoad } = this.state;
console.log('data panel render');
const timeSeries = response.data;
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return (
<div className="loading">
<p>Loading</p>
</div>
);
}
return (
<>
{this.loadingSpinner}
{this.props.children({
timeSeries,
loading,
})}
</>
);
}
private get loadingSpinner(): JSX.Element {
const { loading } = this.state;
if (loading === LoadingState.Loading) {
return (
<div className="panel__loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
return null;
}
}

View File

@ -0,0 +1,84 @@
// Libraries
import React, { ComponentClass, PureComponent } from 'react';
// Services
import { getTimeSrv } from '../time_srv';
// Components
import { PanelHeader } from './PanelHeader';
import { DataPanel } from './DataPanel';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { TimeRange, PanelProps } from 'app/types';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
component: ComponentClass<PanelProps>;
}
export interface State {
refreshCounter: number;
timeRange?: TimeRange;
}
export class PanelChrome extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
refreshCounter: 0,
};
}
componentDidMount() {
this.props.panel.events.on('refresh', this.onRefresh);
this.props.dashboard.panelInitialized(this.props.panel);
}
componentWillUnmount() {
this.props.panel.events.off('refresh', this.onRefresh);
}
onRefresh = () => {
const timeSrv = getTimeSrv();
const timeRange = timeSrv.timeRange();
this.setState({
refreshCounter: this.state.refreshCounter + 1,
timeRange: timeRange,
});
};
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
render() {
const { panel, dashboard } = this.props;
const { datasource, targets } = panel;
const { refreshCounter, timeRange } = this.state;
const PanelComponent = this.props.component;
return (
<div className="panel-container">
<PanelHeader panel={panel} dashboard={dashboard} />
<div className="panel-content">
<DataPanel
datasource={datasource}
queries={targets}
timeRange={timeRange}
isVisible={this.isVisible}
refreshCounter={refreshCounter}
>
{({ loading, timeSeries }) => {
return <PanelComponent loading={loading} timeSeries={timeSeries} timeRange={timeRange} />;
}}
</DataPanel>
</div>
</div>
);
}
}

View File

@ -1,7 +0,0 @@
import { DashboardModel } from '../dashboard_model';
import { PanelLoader } from './PanelLoader';
export interface PanelContainer {
getPanelLoader(): PanelLoader;
getDashboard(): DashboardModel;
}

View File

@ -0,0 +1,121 @@
import React from 'react';
import classNames from 'classnames';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { store } from 'app/store/configureStore';
import { QueriesTab } from './QueriesTab';
import { PanelPlugin, PluginExports } from 'app/types/plugins';
import { VizTypePicker } from './VizTypePicker';
import { updateLocation } from 'app/core/actions';
interface PanelEditorProps {
panel: PanelModel;
dashboard: DashboardModel;
panelType: string;
pluginExports: PluginExports;
onTypeChanged: (newType: PanelPlugin) => void;
}
interface PanelEditorTab {
id: string;
text: string;
icon: string;
}
export class PanelEditor extends React.Component<PanelEditorProps, any> {
tabs: PanelEditorTab[];
constructor(props) {
super(props);
this.tabs = [
{ id: 'queries', text: 'Queries', icon: 'fa fa-database' },
{ id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
];
}
renderQueriesTab() {
return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
}
renderPanelOptions() {
const { pluginExports } = this.props;
if (pluginExports.PanelOptions) {
const PanelOptions = pluginExports.PanelOptions;
return <PanelOptions />;
} else {
return <p>Visualization has no options</p>;
}
}
renderVizTab() {
return (
<div className="viz-editor">
<div className="viz-editor-col1">
<VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
</div>
<div className="viz-editor-col2">
<h5 className="page-heading">Options</h5>
{this.renderPanelOptions()}
</div>
</div>
);
}
onChangeTab = (tab: PanelEditorTab) => {
store.dispatch(
updateLocation({
query: { tab: tab.id },
partial: true,
})
);
};
render() {
const { location } = store.getState();
const activeTab = location.query.tab || 'queries';
return (
<div className="tabbed-view tabbed-view--new">
<div className="tabbed-view-header">
<ul className="gf-tabs">
{this.tabs.map(tab => {
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
})}
</ul>
<button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
<i className="fa fa-remove" />
</button>
</div>
<div className="tabbed-view-body">
{activeTab === 'queries' && this.renderQueriesTab()}
{activeTab === 'visualization' && this.renderVizTab()}
</div>
</div>
);
}
}
interface TabItemParams {
tab: PanelEditorTab;
activeTab: string;
onClick: (tab: PanelEditorTab) => void;
}
function TabItem({ tab, activeTab, onClick }: TabItemParams) {
const tabClasses = classNames({
'gf-tabs-link': true,
active: activeTab === tab.id,
});
return (
<li className="gf-tabs-item" key={tab.id}>
<a className={tabClasses} onClick={() => onClick(tab)}>
<i className={tab.icon} /> {tab.text}
</a>
</li>
);
}

View File

@ -0,0 +1,83 @@
import React from 'react';
import classNames from 'classnames';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { store } from 'app/store/configureStore';
import { updateLocation } from 'app/core/actions';
interface PanelHeaderProps {
panel: PanelModel;
dashboard: DashboardModel;
}
export class PanelHeader extends React.Component<PanelHeaderProps, any> {
onEditPanel = () => {
store.dispatch(
updateLocation({
query: {
panelId: this.props.panel.id,
edit: true,
fullscreen: true,
},
})
);
};
onViewPanel = () => {
store.dispatch(
updateLocation({
query: {
panelId: this.props.panel.id,
edit: false,
fullscreen: true,
},
})
);
};
render() {
const isFullscreen = false;
const isLoading = false;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
return (
<div className={panelHeaderClass}>
<span className="panel-info-corner">
<i className="fa" />
<span className="panel-info-corner-inner" />
</span>
{isLoading && (
<span className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</span>
)}
<div className="panel-title-container">
<span className="panel-title">
<span className="icon-gf panel-alert-icon" />
<span className="panel-title-text">{this.props.panel.title}</span>
<span className="panel-menu-container dropdown">
<span className="fa fa-caret-down panel-menu-toggle" data-toggle="dropdown" />
<ul className="dropdown-menu dropdown-menu--menu panel-menu" role="menu">
<li>
<a onClick={this.onEditPanel}>
<i className="fa fa-fw fa-edit" /> Edit
</a>
</li>
<li>
<a onClick={this.onViewPanel}>
<i className="fa fa-fw fa-eye" /> View
</a>
</li>
</ul>
</span>
<span className="panel-time-info">
<i className="fa fa-clock-o" /> 4m
</span>
</span>
</div>
</div>
);
}
}

View File

@ -0,0 +1,53 @@
// Libraries
import React, { PureComponent } from 'react';
// Services & utils
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
export class QueriesTab extends PureComponent<Props> {
element: any;
component: AngularComponent;
constructor(props) {
super(props);
}
componentDidMount() {
if (!this.element) {
return;
}
const { panel, dashboard } = this.props;
const loader = getAngularLoader();
const template = '<metrics-tab />';
const scopeProps = {
ctrl: {
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
},
};
this.component = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.component) {
this.component.destroy();
}
}
render() {
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
}
}

View File

@ -0,0 +1,69 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
import _ from 'lodash';
interface Props {
currentType: string;
onTypeChanged: (newType: PanelPlugin) => void;
}
interface State {
pluginList: PanelPlugin[];
}
export class VizTypePicker extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
pluginList: this.getPanelPlugins(''),
};
}
getPanelPlugins(filter) {
const panels = _.chain(config.panels)
.filter({ hideFromList: false })
.map(item => item)
.value();
// add sort by sort property
return _.sortBy(panels, 'sort');
}
renderVizPlugin = (plugin, index) => {
const cssClass = classNames({
'viz-picker__item': true,
'viz-picker__item--selected': plugin.id === this.props.currentType,
});
return (
<div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
<img className="viz-picker__item-img" src={plugin.info.logos.small} />
<div className="viz-picker__item-name">{plugin.name}</div>
</div>
);
};
render() {
return (
<div className="viz-picker">
<div className="viz-picker__search">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input type="text" className="gf-form-input" placeholder="Search type" />
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
</div>
<div className="viz-picker__items">
<CustomScrollbar>
<div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
</CustomScrollbar>
</div>
</div>
);
}
}

View File

@ -42,6 +42,8 @@ export class DashNavCtrl {
} else if (search.fullscreen) { } else if (search.fullscreen) {
delete search.fullscreen; delete search.fullscreen;
delete search.edit; delete search.edit;
delete search.tab;
delete search.panelId;
} }
this.$location.search(search); this.$location.search(search);
} }

View File

@ -13,6 +13,13 @@ const notPersistedProperties: { [str: string]: boolean } = {
events: true, events: true,
fullscreen: true, fullscreen: true,
isEditing: true, isEditing: true,
hasRefreshed: true,
};
const defaults: any = {
gridPos: { x: 0, y: 0, h: 3, w: 6 },
datasource: null,
targets: [{}],
}; };
export class PanelModel { export class PanelModel {
@ -31,10 +38,14 @@ export class PanelModel {
collapsed?: boolean; collapsed?: boolean;
panels?: any; panels?: any;
soloMode?: boolean; soloMode?: boolean;
targets: any[];
datasource: string;
thresholds?: any;
// non persisted // non persisted
fullscreen: boolean; fullscreen: boolean;
isEditing: boolean; isEditing: boolean;
hasRefreshed: boolean;
events: Emitter; events: Emitter;
constructor(model) { constructor(model) {
@ -45,9 +56,8 @@ export class PanelModel {
this[property] = model[property]; this[property] = model[property];
} }
if (!this.gridPos) { // defaults
this.gridPos = { x: 0, y: 0, h: 3, w: 6 }; _.defaultsDeep(this, _.cloneDeep(defaults));
}
} }
getSaveModel() { getSaveModel() {
@ -57,6 +67,10 @@ export class PanelModel {
continue; continue;
} }
if (_.isEqual(this[property], defaults[property])) {
continue;
}
model[property] = _.cloneDeep(this[property]); model[property] = _.cloneDeep(this[property]);
} }
@ -82,7 +96,6 @@ export class PanelModel {
this.gridPos.h = newPos.h; this.gridPos.h = newPos.h;
if (sizeChanged) { if (sizeChanged) {
console.log('PanelModel sizeChanged event and render events fired');
this.events.emit('panel-size-changed'); this.events.emit('panel-size-changed');
} }
} }
@ -91,6 +104,34 @@ export class PanelModel {
this.events.emit('panel-size-changed'); this.events.emit('panel-size-changed');
} }
refresh() {
this.hasRefreshed = true;
this.events.emit('refresh');
}
render() {
if (!this.hasRefreshed) {
this.refresh();
} else {
this.events.emit('render');
}
}
panelInitialized() {
this.events.emit('panel-initialized');
}
initEditMode() {
this.events.emit('panel-init-edit-mode');
}
changeType(pluginId: string) {
this.type = pluginId;
delete this.thresholds;
delete this.alert;
}
destroy() { destroy() {
this.events.removeAllListeners(); this.events.removeAllListeners();
} }

View File

@ -32,7 +32,7 @@ export class SettingsCtrl {
this.$scope.$on('$destroy', () => { this.$scope.$on('$destroy', () => {
this.dashboard.updateSubmenuVisibility(); this.dashboard.updateSubmenuVisibility();
this.$rootScope.$broadcast('refresh'); this.dashboard.startRefresh();
setTimeout(() => { setTimeout(() => {
this.$rootScope.appEvent('dash-scroll', { restore: true }); this.$rootScope.appEvent('dash-scroll', { restore: true });
}); });

View File

@ -46,8 +46,7 @@ export class ShareSnapshotCtrl {
$scope.loading = true; $scope.loading = true;
$scope.snapshot.external = external; $scope.snapshot.external = external;
$scope.dashboard.startRefresh();
$rootScope.$broadcast('refresh');
$timeout(() => { $timeout(() => {
$scope.saveSnapshot(external); $scope.saveSnapshot(external);

Some files were not shown because too many files have changed in this diff Show More