Merge remote-tracking branch 'upstream/master' into graph-legend-to-react

This commit is contained in:
Alexander Zobnin 2018-10-22 17:18:35 +03:00
commit 5a4c362985
No known key found for this signature in database
GPG Key ID: E17E9ABACEFA59EB
177 changed files with 3696 additions and 857 deletions

View File

@ -238,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'
@ -254,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
@ -267,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
@ -313,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
@ -356,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:
@ -385,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

View File

@ -2,25 +2,37 @@
### 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
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) * **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) * **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)
### 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) * **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) * **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) * **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**: 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) * **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) * **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) * **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 * **HTTP API**: Fix /api/org/users so that query and limit querystrings works

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.

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

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

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

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

@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() {
datasourceRoute.Get("/", Wrap(GetDataSources)) datasourceRoute.Get("/", Wrap(GetDataSources))
datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource)) datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID)) datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById))
datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName)) datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
datasourceRoute.Get("/:id", Wrap(GetDataSourceByID)) datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName)) datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
}, reqOrgAdmin) }, reqOrgAdmin)
apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn) apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn)
apiRoute.Get("/plugins", Wrap(GetPluginList)) apiRoute.Get("/plugins", Wrap(GetPluginList))
apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID)) apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))
@ -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

@ -2,6 +2,7 @@ package api
import ( import (
"fmt" "fmt"
"github.com/pkg/errors"
"time" "time"
"github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/api/pluginproxy"
@ -14,6 +15,20 @@ import (
const HeaderNameNoBackendCache = "X-Grafana-NoCache" const HeaderNameNoBackendCache = "X-Grafana-NoCache"
func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) { func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) {
userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{
User: c.SignedInUser,
}
if err := bus.Dispatch(&userPermissionsQuery); err != nil {
if err != bus.ErrHandlerNotFound {
return nil, err
}
} else {
permissionType, exists := userPermissionsQuery.Result[id]
if exists && permissionType != m.DsPermissionQuery {
return nil, errors.New("User not allowed to access datasource")
}
}
nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
cacheKey := fmt.Sprintf("ds-%d", id) cacheKey := fmt.Sprintf("ds-%d", id)
@ -38,7 +53,9 @@ func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.Data
func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c) dsId := c.ParamsInt64(":id")
ds, err := hs.getDatasourceFromCache(dsId, c)
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)
return return

View File

@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response {
result := make(dtos.DataSourceList, 0) result := make(dtos.DataSourceList, 0)
for _, ds := range query.Result { for _, ds := range query.Result {
dsItem := dtos.DataSourceListItemDTO{ dsItem := dtos.DataSourceListItemDTO{
Id: ds.Id,
OrgId: ds.OrgId, OrgId: ds.OrgId,
Id: ds.Id,
Name: ds.Name, Name: ds.Name,
Url: ds.Url, Url: ds.Url,
Type: ds.Type, Type: ds.Type,
@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response {
return JSON(200, &result) return JSON(200, &result)
} }
func GetDataSourceByID(c *m.ReqContext) Response { func GetDataSourceById(c *m.ReqContext) Response {
query := m.GetDataSourceByIdQuery{ query := m.GetDataSourceByIdQuery{
Id: c.ParamsInt64(":id"), Id: c.ParamsInt64(":id"),
OrgId: c.OrgId, OrgId: c.OrgId,
@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response {
return JSON(200, &dtos) return JSON(200, &dtos)
} }
func DeleteDataSourceByID(c *m.ReqContext) Response { func DeleteDataSourceById(c *m.ReqContext) Response {
id := c.ParamsInt64(":id") id := c.ParamsInt64(":id")
if id <= 0 { if id <= 0 {
return Error(400, "Missing valid datasource id", nil) return Error(400, "Missing valid datasource id", nil)
} }
ds, err := getRawDataSourceByID(id, c.OrgId) ds, err := getRawDataSourceById(id, c.OrgId)
if err != nil { if err != nil {
return Error(400, "Failed to delete datasource", nil) return Error(400, "Failed to delete datasource", nil)
} }
@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil return nil
} }
ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId) ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
if err != nil { if err != nil {
return err return err
} }
@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil return nil
} }
func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) { func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) {
query := m.GetDataSourceByIdQuery{ query := m.GetDataSourceByIdQuery{
Id: id, Id: id,
OrgId: orgID, OrgId: orgID,
@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response {
} }
// Get /api/datasources/id/:name // Get /api/datasources/id/:name
func GetDataSourceIDByName(c *m.ReqContext) Response { func GetDataSourceIdByName(c *m.ReqContext) Response {
query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId} query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId}
if err := bus.Dispatch(&query); err != nil { if err := bus.Dispatch(&query); err != nil {

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 {
@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
return nil, err return nil, err
} }
orgDataSources = query.Result dsFilterQuery := m.DatasourcesPermissionFilterQuery{
User: c.SignedInUser,
Datasources: query.Result,
}
if err := bus.Dispatch(&dsFilterQuery); err != nil {
if err != bus.ErrHandlerNotFound {
return nil, err
}
orgDataSources = query.Result
} else {
orgDataSources = dsFilterQuery.Result
}
} }
datasources := make(map[string]interface{}) datasources := make(map[string]interface{})
@ -120,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,
@ -183,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

@ -18,7 +18,7 @@ const (
) )
func (hs *HTTPServer) 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
} }

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

@ -30,6 +30,7 @@ var (
ErrDataSourceNameExists = errors.New("Data source with same name already exists") ErrDataSourceNameExists = errors.New("Data source with same name already exists")
ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource") ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource")
ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration.") ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration.")
ErrDataSourceAccessDenied = errors.New("Data source access denied")
) )
type DsAccess string type DsAccess string
@ -167,6 +168,7 @@ type DeleteDataSourceByNameCommand struct {
type GetDataSourcesQuery struct { type GetDataSourcesQuery struct {
OrgId int64 OrgId int64
User *SignedInUser
Result []*DataSource Result []*DataSource
} }
@ -187,6 +189,31 @@ type GetDataSourceByNameQuery struct {
} }
// --------------------- // ---------------------
// EVENTS // Permissions
type DataSourceCreatedEvent struct { // ---------------------
type DsPermissionType int
const (
DsPermissionNoAccess DsPermissionType = iota
DsPermissionQuery
)
func (p DsPermissionType) String() string {
names := map[int]string{
int(DsPermissionQuery): "Query",
int(DsPermissionNoAccess): "No Access",
}
return names[int(p)]
}
type GetDataSourcePermissionsForUserQuery struct {
User *SignedInUser
Result map[int64]DsPermissionType
}
type DatasourcesPermissionFilterQuery struct {
User *SignedInUser
Datasources []*DataSource
Result []*DataSource
} }

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

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

@ -27,6 +27,7 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id} datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
has, err := x.Get(&datasource) has, err := x.Get(&datasource)
if err != nil { if err != nil {
return err return err
} }

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

@ -53,6 +53,7 @@ type SqlStore struct {
dbCfg DatabaseConfig dbCfg DatabaseConfig
engine *xorm.Engine engine *xorm.Engine
log log.Logger log log.Logger
Dialect migrator.Dialect
skipEnsureAdmin bool skipEnsureAdmin bool
} }
@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error {
} }
ss.engine = engine ss.engine = engine
ss.Dialect = migrator.NewDialect(ss.engine)
// temporarily still set global var // temporarily still set global var
x = engine x = engine
dialect = migrator.NewDialect(x) dialect = ss.Dialect
migrator := migrator.NewMigrator(x) migrator := migrator.NewMigrator(x)
migrations.AddMigrations(migrator) migrations.AddMigrations(migrator)
@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore {
t.Fatalf("Failed to init test database: %v", err) t.Fatalf("Failed to init test database: %v", err)
} }
dialect = migrator.NewDialect(engine) sqlstore.Dialect = migrator.NewDialect(engine)
// temp global var until we get rid of global vars
dialect = sqlstore.Dialect
if err := dialect.CleanDB(); err != nil { if err := dialect.CleanDB(); err != nil {
t.Fatalf("Failed to clean test db %v", err) t.Fatalf("Failed to clean test db %v", err)
} }

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

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

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

@ -18,6 +18,10 @@ export interface Props {
} }
class AddPermissions extends Component<Props, NewDashboardAclItem> { class AddPermissions extends Component<Props, NewDashboardAclItem> {
static defaultProps = {
showPermissionLevels: true,
};
constructor(props) { constructor(props) {
super(props); super(props);
this.state = this.getCleanState(); this.state = this.getCleanState();

View File

@ -22,10 +22,6 @@ export interface Props {
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value); const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
class DescriptionPicker extends Component<Props, any> { class DescriptionPicker extends Component<Props, any> {
constructor(props) {
super(props);
}
render() { render() {
const { optionsWithDesc, onSelected, disabled, className, value } = this.props; const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
const selectedOption = getSelectedOption(optionsWithDesc, value); const selectedOption = getSelectedOption(optionsWithDesc, value);

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

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

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

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

View File

@ -14,7 +14,7 @@ jest.mock('app/core/store', () => ({
})); }));
describe('AddPanelPanel', () => { describe('AddPanelPanel', () => {
let wrapper, dashboardMock, getPanelContainer, panel; let wrapper, dashboardMock, panel;
beforeEach(() => { beforeEach(() => {
config.panels = [ config.panels = [
@ -23,6 +23,9 @@ describe('AddPanelPanel', () => {
hideFromList: false, hideFromList: false,
name: 'Singlestat', name: 'Singlestat',
sort: 2, sort: 2,
module: '',
baseUrl: '',
meta: {},
info: { info: {
logos: { logos: {
small: '', small: '',
@ -34,6 +37,9 @@ describe('AddPanelPanel', () => {
hideFromList: true, hideFromList: true,
name: 'Hidden', name: 'Hidden',
sort: 100, sort: 100,
meta: {},
module: '',
baseUrl: '',
info: { info: {
logos: { logos: {
small: '', small: '',
@ -45,6 +51,9 @@ describe('AddPanelPanel', () => {
hideFromList: false, hideFromList: false,
name: 'Graph', name: 'Graph',
sort: 1, sort: 1,
meta: {},
module: '',
baseUrl: '',
info: { info: {
logos: { logos: {
small: '', small: '',
@ -56,6 +65,9 @@ describe('AddPanelPanel', () => {
hideFromList: false, hideFromList: false,
name: 'Zabbix', name: 'Zabbix',
sort: 100, sort: 100,
meta: {},
module: '',
baseUrl: '',
info: { info: {
logos: { logos: {
small: '', small: '',
@ -67,6 +79,9 @@ describe('AddPanelPanel', () => {
hideFromList: false, hideFromList: false,
name: 'Piechart', name: 'Piechart',
sort: 100, sort: 100,
meta: {},
module: '',
baseUrl: '',
info: { info: {
logos: { logos: {
small: '', small: '',
@ -77,13 +92,8 @@ describe('AddPanelPanel', () => {
dashboardMock = { toggleRow: jest.fn() }; dashboardMock = { toggleRow: jest.fn() };
getPanelContainer = jest.fn().mockReturnValue({
getDashboard: jest.fn().mockReturnValue(dashboardMock),
getPanelLoader: jest.fn(),
});
panel = new PanelModel({ collapsed: false }); panel = new PanelModel({ collapsed: false });
wrapper = shallow(<AddPanelPanel panel={panel} getPanelContainer={getPanelContainer} />); wrapper = shallow(<AddPanelPanel panel={panel} dashboard={dashboardMock} />);
}); });
it('should fetch all panels sorted with core plugins first', () => { it('should fetch all panels sorted with core plugins first', () => {

View File

@ -4,7 +4,7 @@ import { DashboardRow } from '../dashgrid/DashboardRow';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';
describe('DashboardRow', () => { describe('DashboardRow', () => {
let wrapper, panel, getPanelContainer, dashboardMock; let wrapper, panel, dashboardMock;
beforeEach(() => { beforeEach(() => {
dashboardMock = { dashboardMock = {
@ -14,13 +14,8 @@ describe('DashboardRow', () => {
}, },
}; };
getPanelContainer = jest.fn().mockReturnValue({
getDashboard: jest.fn().mockReturnValue(dashboardMock),
getPanelLoader: jest.fn(),
});
panel = new PanelModel({ collapsed: false }); panel = new PanelModel({ collapsed: false });
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />); wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
}); });
it('Should not have collapsed class when collaped is false', () => { it('Should not have collapsed class when collaped is false', () => {
@ -41,14 +36,14 @@ describe('DashboardRow', () => {
it('should not show row drag handle when cannot edit', () => { it('should not show row drag handle when cannot edit', () => {
dashboardMock.meta.canEdit = false; dashboardMock.meta.canEdit = false;
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />); wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0); expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0);
}); });
it('should have zero actions when cannot edit', () => { it('should have zero actions when cannot edit', () => {
dashboardMock.meta.canEdit = false; dashboardMock.meta.canEdit = false;
panel = new PanelModel({ collapsed: false }); panel = new PanelModel({ collapsed: false });
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />); wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0); expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
}); });
}); });

View File

@ -240,5 +240,5 @@ stubs['-- Grafana --'] = {
}; };
function getStub(arg) { function getStub(arg) {
return Promise.resolve(stubs[arg]); return Promise.resolve(stubs[arg || 'gfdb']);
} }

View File

@ -2,6 +2,7 @@
import 'app/features/dashboard/view_state_srv'; import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config'; import config from 'app/core/config';
import { DashboardViewState } from '../view_state_srv'; import { DashboardViewState } from '../view_state_srv';
import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => { describe('when updating view state', () => {
const location = { const location = {
@ -10,14 +11,13 @@ describe('when updating view state', () => {
}; };
const $scope = { const $scope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(() => {}), onAppEvent: jest.fn(() => {}),
dashboard: { dashboard: new DashboardModel({
meta: {}, panels: [{ id: 1 }],
panels: [], }),
},
}; };
const $rootScope = {};
let viewState; let viewState;
beforeEach(() => { beforeEach(() => {
@ -33,7 +33,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => { location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 }; return { fullscreen: true, edit: true, panelId: 1 };
}); });
viewState = new DashboardViewState($scope, location, {}, $rootScope); viewState = new DashboardViewState($scope, location, {});
}); });
it('should update querystring and view state', () => { it('should update querystring and view state', () => {
@ -55,7 +55,7 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => { describe('to fullscreen false', () => {
beforeEach(() => { beforeEach(() => {
viewState = new DashboardViewState($scope, location, {}, $rootScope); viewState = new DashboardViewState($scope, location, {});
}); });
it('should remove params from query string', () => { it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true }); viewState.update({ fullscreen: true, panelId: 1, edit: true });

View File

@ -1,7 +1,8 @@
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk'; import { ThunkAction } from 'redux-thunk';
import { getBackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv } from 'app/core/services/backend_srv';
import appEvents from 'app/core/app_events';
import { loadPluginDashboards } from '../../plugins/state/actions';
import { import {
DashboardAcl, DashboardAcl,
DashboardAclDTO, DashboardAclDTO,
@ -113,3 +114,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
await dispatch(getDashboardPermissions(dashboardId)); await dispatch(getDashboardPermissions(dashboardId));
}; };
} }
export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post('/api/dashboards/import', data);
appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
dispatch(loadPluginDashboards());
};
}
export function removeDashboard(uri: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().delete(`/api/dashboards/${uri}`);
dispatch(loadPluginDashboards());
};
}

View File

@ -7,13 +7,13 @@ export class SubmenuCtrl {
dashboard: any; dashboard: any;
/** @ngInject */ /** @ngInject */
constructor(private $rootScope, private variableSrv, private $location) { constructor(private variableSrv, private $location) {
this.annotations = this.dashboard.templating.list; this.annotations = this.dashboard.templating.list;
this.variables = this.variableSrv.variables; this.variables = this.variableSrv.variables;
} }
annotationStateChanged() { annotationStateChanged() {
this.$rootScope.$broadcast('refresh'); this.dashboard.startRefresh();
} }
variableUpdated(variable) { variableUpdated(variable) {

View File

@ -1,8 +1,14 @@
// Libraries
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
import coreModule from 'app/core/core_module';
// Utils
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import coreModule from 'app/core/core_module';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
// Types
import { TimeRange } from 'app/types';
export class TimeSrv { export class TimeSrv {
time: any; time: any;
@ -24,7 +30,6 @@ export class TimeSrv {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (this.autoRefreshBlocked && document.visibilityState === 'visible') { if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
this.autoRefreshBlocked = false; this.autoRefreshBlocked = false;
this.refreshDashboard(); this.refreshDashboard();
} }
}); });
@ -142,7 +147,7 @@ export class TimeSrv {
} }
refreshDashboard() { refreshDashboard() {
this.$rootScope.$broadcast('refresh'); this.dashboard.timeRangeUpdated();
} }
private startNextRefreshTimer(afterMs) { private startNextRefreshTimer(afterMs) {
@ -201,7 +206,7 @@ export class TimeSrv {
return range; return range;
} }
timeRange() { timeRange(): TimeRange {
// make copies if they are moment (do not want to return out internal moment, because they are mutable!) // make copies if they are moment (do not want to return out internal moment, because they are mutable!)
const raw = { const raw = {
from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from, from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from,
@ -223,17 +228,21 @@ export class TimeSrv {
const timespan = range.to.valueOf() - range.from.valueOf(); const timespan = range.to.valueOf() - range.from.valueOf();
const center = range.to.valueOf() - timespan / 2; const center = range.to.valueOf() - timespan / 2;
let to = center + timespan * factor / 2; const to = center + timespan * factor / 2;
let from = center - timespan * factor / 2; const from = center - timespan * factor / 2;
if (to > Date.now() && range.to <= Date.now()) {
const offset = to - Date.now();
from = from - offset;
to = Date.now();
}
this.setTime({ from: moment.utc(from), to: moment.utc(to) }); this.setTime({ from: moment.utc(from), to: moment.utc(to) });
} }
} }
let singleton;
export function setTimeSrv(srv: TimeSrv) {
singleton = srv;
}
export function getTimeSrv(): TimeSrv {
return singleton;
}
coreModule.service('timeSrv', TimeSrv); coreModule.service('timeSrv', TimeSrv);

View File

@ -5,7 +5,7 @@
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-10">Timezone</label> <label class="gf-form-label width-10">Timezone</label>
<div class="gf-form-select-wrapper"> <div class="gf-form-select-wrapper">
<select ng-model="ctrl.dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]" ng-change="timezoneChanged()"></select> <select ng-model="ctrl.dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]"></select>
</div> </div>
</div> </div>

View File

@ -31,9 +31,10 @@ export class TimePickerCtrl {
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope); $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
$rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope); $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope);
$rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope);
$rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope); $rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope);
this.dashboard.on('refresh', this.onRefresh.bind(this), $scope);
// init options // init options
this.panel = this.dashboard.timepicker; this.panel = this.dashboard.timepicker;
_.defaults(this.panel, TimePickerCtrl.defaults); _.defaults(this.panel, TimePickerCtrl.defaults);

View File

@ -1,6 +1,7 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from './dashboard_model'; import { DashboardModel } from './dashboard_model';
// represents the transient view state // represents the transient view state
@ -10,12 +11,11 @@ export class DashboardViewState {
panelScopes: any; panelScopes: any;
$scope: any; $scope: any;
dashboard: DashboardModel; dashboard: DashboardModel;
editStateChanged: any;
fullscreenPanel: any; fullscreenPanel: any;
oldTimeRange: any; oldTimeRange: any;
/** @ngInject */ /** @ngInject */
constructor($scope, private $location, private $timeout, private $rootScope) { constructor($scope, private $location, private $timeout) {
const self = this; const self = this;
self.state = {}; self.state = {};
self.panelScopes = []; self.panelScopes = [];
@ -33,10 +33,6 @@ export class DashboardViewState {
self.update(payload); self.update(payload);
}); });
$scope.onAppEvent('panel-initialized', (evt, payload) => {
self.registerPanel(payload.scope);
});
// this marks changes to location during this digest cycle as not to add history item // this marks changes to location during this digest cycle as not to add history item
// don't want url changes like adding orgId to add browser history // don't want url changes like adding orgId to add browser history
$location.replace(); $location.replace();
@ -75,9 +71,6 @@ export class DashboardViewState {
} }
} }
// remember if editStateChanged
this.editStateChanged = (state.edit || false) !== (this.state.edit || false);
_.extend(this.state, state); _.extend(this.state, state);
this.dashboard.meta.fullscreen = this.state.fullscreen; this.dashboard.meta.fullscreen = this.state.fullscreen;
@ -124,110 +117,59 @@ export class DashboardViewState {
} }
syncState() { syncState() {
if (this.panelScopes.length === 0) {
return;
}
if (this.dashboard.meta.fullscreen) { if (this.dashboard.meta.fullscreen) {
const panelScope = this.getPanelScope(this.state.panelId); const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panelScope) {
if (!panel) {
return; return;
} }
if (this.fullscreenPanel) { if (!panel.fullscreen) {
// if already fullscreen this.enterFullscreen(panel);
if (this.fullscreenPanel === panelScope && this.editStateChanged === false) { } else {
return; // already in fullscreen view just update the view mode
} else { this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
this.leaveFullscreen(false);
}
}
if (!panelScope.ctrl.editModeInitiated) {
panelScope.ctrl.initEditMode();
}
if (!panelScope.ctrl.fullscreen) {
this.enterFullscreen(panelScope);
} }
} else if (this.fullscreenPanel) { } else if (this.fullscreenPanel) {
this.leaveFullscreen(true); this.leaveFullscreen();
} }
} }
getPanelScope(id) { leaveFullscreen() {
return _.find(this.panelScopes, panelScope => { const panel = this.fullscreenPanel;
return panelScope.ctrl.panel.id === id;
});
}
leaveFullscreen(render) { this.dashboard.setViewMode(panel, false, false);
const self = this;
const ctrl = self.fullscreenPanel.ctrl;
ctrl.editMode = false; delete this.fullscreenPanel;
ctrl.fullscreen = false;
this.dashboard.setViewMode(ctrl.panel, false, false);
this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id });
this.$scope.appEvent('dash-scroll', { restore: true });
if (!render) {
return false;
}
this.$timeout(() => { this.$timeout(() => {
if (self.oldTimeRange !== ctrl.range) { appEvents.emit('dash-scroll', { restore: true });
self.$rootScope.$broadcast('refresh');
if (this.oldTimeRange !== this.dashboard.time) {
this.dashboard.startRefresh();
} else { } else {
self.$rootScope.$broadcast('render'); this.dashboard.render();
} }
delete self.fullscreenPanel;
}); });
return true;
} }
enterFullscreen(panelScope) { enterFullscreen(panel) {
const ctrl = panelScope.ctrl; const isEditing = this.state.edit && this.dashboard.meta.canEdit;
ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit; this.oldTimeRange = this.dashboard.time;
ctrl.fullscreen = true; this.fullscreenPanel = panel;
this.oldTimeRange = ctrl.range;
this.fullscreenPanel = panelScope;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode() // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode); this.dashboard.setViewMode(panel, true, isEditing);
this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
}
registerPanel(panelScope) {
const self = this;
self.panelScopes.push(panelScope);
if (!self.dashboard.meta.soloMode) {
if (self.state.panelId === panelScope.ctrl.panel.id) {
if (self.state.edit) {
panelScope.ctrl.editPanel();
} else {
panelScope.ctrl.viewPanel();
}
}
}
const unbind = panelScope.$on('$destroy', () => {
self.panelScopes = _.without(self.panelScopes, panelScope);
unbind();
});
} }
} }
/** @ngInject */ /** @ngInject */
export function dashboardViewStateSrv($location, $timeout, $rootScope) { export function dashboardViewStateSrv($location, $timeout) {
return { return {
create: $scope => { create: $scope => {
return new DashboardViewState($scope, $location, $timeout, $rootScope); return new DashboardViewState($scope, $location, $timeout);
}, },
}; };
} }

View File

@ -0,0 +1,63 @@
import React from 'react';
import { shallow } from 'enzyme';
import DashboardsTable, { Props } from './DashboardsTable';
import { PluginDashboard } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
dashboards: [] as PluginDashboard[],
onImport: jest.fn(),
onRemove: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<DashboardsTable {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render table', () => {
const wrapper = setup({
dashboards: [
{
dashboardId: 0,
description: '',
folderId: 0,
imported: false,
importedRevision: 0,
importedUri: '',
importedUrl: '',
path: 'dashboards/carbon_metrics.json',
pluginId: 'graphite',
removed: false,
revision: 1,
slug: '',
title: 'Graphite Carbon Metrics',
},
{
dashboardId: 0,
description: '',
folderId: 0,
imported: true,
importedRevision: 0,
importedUri: '',
importedUrl: '',
path: 'dashboards/carbon_metrics.json',
pluginId: 'graphite',
removed: false,
revision: 1,
slug: '',
title: 'Graphite Carbon Metrics',
},
],
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,55 @@
import React, { SFC } from 'react';
import { PluginDashboard } from '../../types';
export interface Props {
dashboards: PluginDashboard[];
onImport: (dashboard, overwrite) => void;
onRemove: (dashboard) => void;
}
const DashboardsTable: SFC<Props> = ({ dashboards, onImport, onRemove }) => {
function buttonText(dashboard: PluginDashboard) {
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
}
return (
<table className="filter-table">
<tbody>
{dashboards.map((dashboard, index) => {
return (
<tr key={`${dashboard.dashboardId}-${index}`}>
<td className="width-1">
<i className="icon-gf icon-gf-dashboard" />
</td>
<td>
{dashboard.imported ? (
<a href={dashboard.importedUrl}>{dashboard.title}</a>
) : (
<span>{dashboard.title}</span>
)}
</td>
<td style={{ textAlign: 'right' }}>
{!dashboard.imported ? (
<button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, false)}>
Import
</button>
) : (
<button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, true)}>
{buttonText(dashboard)}
</button>
)}
{dashboard.imported && (
<button className="btn btn-danger btn-small" onClick={() => onRemove(dashboard)}>
<i className="fa fa-trash" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
};
export default DashboardsTable;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourceDashboards, Props } from './DataSourceDashboards';
import { DataSource, NavModel, PluginDashboard } from 'app/types';
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
dashboards: [] as PluginDashboard[],
dataSource: {} as DataSource,
pageId: 1,
importDashboard: jest.fn(),
loadDataSource: jest.fn(),
loadPluginDashboards: jest.fn(),
removeDashboard: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<DataSourceDashboards {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,93 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import DashboardTable from './DashboardsTable';
import { DataSource, NavModel, PluginDashboard } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import { loadDataSource } from './state/actions';
import { loadPluginDashboards } from '../plugins/state/actions';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { getDataSource } from './state/selectors';
export interface Props {
navModel: NavModel;
dashboards: PluginDashboard[];
dataSource: DataSource;
pageId: number;
importDashboard: typeof importDashboard;
loadDataSource: typeof loadDataSource;
loadPluginDashboards: typeof loadPluginDashboards;
removeDashboard: typeof removeDashboard;
}
export class DataSourceDashboards extends PureComponent<Props> {
async componentDidMount() {
const { loadDataSource, pageId } = this.props;
await loadDataSource(pageId);
this.props.loadPluginDashboards();
}
onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
const { dataSource, importDashboard } = this.props;
const data = {
pluginId: dashboard.pluginId,
path: dashboard.path,
overwrite: overwrite,
inputs: [],
};
if (dataSource) {
data.inputs.push({
name: '*',
type: 'datasource',
pluginId: dataSource.type,
value: dataSource.name,
});
}
importDashboard(data, dashboard.title);
};
onRemove = (dashboard: PluginDashboard) => {
this.props.removeDashboard(dashboard.importedUri);
};
render() {
const { dashboards, navModel } = this.props;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
<DashboardTable
dashboards={dashboards}
onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
onRemove={dashboard => this.onRemove(dashboard)}
/>
</div>
</div>
);
}
}
function mapStateToProps(state) {
const pageId = getRouteParamsId(state.location);
return {
navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`),
pageId: pageId,
dashboards: state.plugins.dashboards,
dataSource: getDataSource(state.dataSources, pageId),
};
}
const mapDispatchToProps = {
importDashboard,
loadDataSource,
loadPluginDashboards,
removeDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards));

View File

@ -0,0 +1,125 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { DataSource, Plugin } from 'app/types';
export interface Props {
dataSource: DataSource;
dataSourceMeta: Plugin;
}
interface State {
name: string;
}
enum DataSourceStates {
Alpha = 'alpha',
Beta = 'beta',
}
export class DataSourceSettings extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
name: props.dataSource.name,
};
}
onNameChange = event => {
this.setState({
name: event.target.value,
});
};
onSubmit = event => {
event.preventDefault();
console.log(event);
};
onDelete = event => {
console.log(event);
};
isReadyOnly() {
return this.props.dataSource.readOnly === true;
}
shouldRenderInfoBox() {
const { state } = this.props.dataSourceMeta;
return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
}
getInfoText() {
const { dataSourceMeta } = this.props;
switch (dataSourceMeta.state) {
case DataSourceStates.Alpha:
return (
'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
' will include breaking changes.'
);
case DataSourceStates.Beta:
return (
'This plugin is marked as being in a beta development state. This means it is in currently in active' +
' development and could be missing important features.'
);
}
return null;
}
render() {
const { name } = this.state;
return (
<div>
<h3 className="page-sub-heading">Settings</h3>
<form onSubmit={this.onSubmit}>
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<span className="gf-form-label width-10">Name</span>
<input
className="gf-form-input max-width-23"
type="text"
value={name}
placeholder="name"
onChange={this.onNameChange}
required
/>
</div>
</div>
</div>
{this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
{this.isReadyOnly() && (
<div className="grafana-info-box span8">
This datasource was added by config and cannot be modified using the UI. Please contact your server admin
to update this datasource.
</div>
)}
<div className="gf-form-button-row">
<button type="submit" className="btn btn-success" disabled={this.isReadyOnly()} onClick={this.onSubmit}>
Save &amp; Test
</button>
<button type="submit" className="btn btn-danger" disabled={this.isReadyOnly()} onClick={this.onDelete}>
Delete
</button>
<a className="btn btn-inverse" href="datasources">
Back
</a>
</div>
</form>
</div>
);
}
}
function mapStateToProps(state) {
return {
dataSource: state.dataSources.dataSource,
dataSourceMeta: state.dataSources.dataSourceMeta,
};
}
export default connect(mapStateToProps)(DataSourceSettings);

View File

@ -4,7 +4,6 @@ import { hot } from 'react-hot-loader';
import PageHeader from 'app/core/components/PageHeader/PageHeader'; import PageHeader from 'app/core/components/PageHeader/PageHeader';
import { NavModel, Plugin } from 'app/types'; import { NavModel, Plugin } from 'app/types';
import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions'; import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
import { updateLocation } from '../../core/actions';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
import { getDataSourceTypes } from './state/selectors'; import { getDataSourceTypes } from './state/selectors';
@ -13,7 +12,6 @@ export interface Props {
dataSourceTypes: Plugin[]; dataSourceTypes: Plugin[];
addDataSource: typeof addDataSource; addDataSource: typeof addDataSource;
loadDataSourceTypes: typeof loadDataSourceTypes; loadDataSourceTypes: typeof loadDataSourceTypes;
updateLocation: typeof updateLocation;
dataSourceTypeSearchQuery: string; dataSourceTypeSearchQuery: string;
setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery; setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
} }
@ -81,7 +79,6 @@ function mapStateToProps(state) {
const mapDispatchToProps = { const mapDispatchToProps = {
addDataSource, addDataSource,
loadDataSourceTypes, loadDataSourceTypes,
updateLocation,
setDataSourceTypeSearchQuery, setDataSourceTypeSearchQuery,
}; };

View File

@ -0,0 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<table
className="filter-table"
>
<tbody />
</table>
`;
exports[`Render should render table 1`] = `
<table
className="filter-table"
>
<tbody>
<tr
key="0-0"
>
<td
className="width-1"
>
<i
className="icon-gf icon-gf-dashboard"
/>
</td>
<td>
<span>
Graphite Carbon Metrics
</span>
</td>
<td
style={
Object {
"textAlign": "right",
}
}
>
<button
className="btn btn-secondary btn-small"
onClick={[Function]}
>
Import
</button>
</td>
</tr>
<tr
key="0-1"
>
<td
className="width-1"
>
<i
className="icon-gf icon-gf-dashboard"
/>
</td>
<td>
<a
href=""
>
Graphite Carbon Metrics
</a>
</td>
<td
style={
Object {
"textAlign": "right",
}
}
>
<button
className="btn btn-secondary btn-small"
onClick={[Function]}
>
Update
</button>
<button
className="btn btn-danger btn-small"
onClick={[Function]}
>
<i
className="fa fa-trash"
/>
</button>
</td>
</tr>
</tbody>
</table>
`;

View File

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<DashboardsTable
dashboards={Array []}
onImport={[Function]}
onRemove={[Function]}
/>
</div>
</div>
`;

View File

@ -2,12 +2,15 @@ import { ThunkAction } from 'redux-thunk';
import { DataSource, Plugin, StoreState } from 'app/types'; import { DataSource, Plugin, StoreState } from 'app/types';
import { getBackendSrv } from '../../../core/services/backend_srv'; import { getBackendSrv } from '../../../core/services/backend_srv';
import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
import { updateLocation } from '../../../core/actions'; import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
import { UpdateLocationAction } from '../../../core/actions/location'; import { UpdateLocationAction } from '../../../core/actions/location';
import { buildNavModel } from './navModel';
export enum ActionTypes { export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES', LoadDataSources = 'LOAD_DATA_SOURCES',
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES', LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
LoadDataSource = 'LOAD_DATA_SOURCE',
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY', SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE', SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY', SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction {
payload: string; payload: string;
} }
export interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
payload: DataSource;
}
export interface LoadDataSourceMetaAction {
type: ActionTypes.LoadDataSourceMeta;
payload: Plugin;
}
const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({ const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources, type: ActionTypes.LoadDataSources,
payload: dataSources, payload: dataSources,
}); });
const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
type: ActionTypes.LoadDataSource,
payload: dataSource,
});
const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
type: ActionTypes.LoadDataSourceMeta,
payload: dataSourceMeta,
});
const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({ const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
type: ActionTypes.LoadDataSourceTypes, type: ActionTypes.LoadDataSourceTypes,
payload: dataSourceTypes, payload: dataSourceTypes,
@ -69,7 +92,10 @@ export type Action =
| SetDataSourcesLayoutModeAction | SetDataSourcesLayoutModeAction
| UpdateLocationAction | UpdateLocationAction
| LoadDataSourceTypesAction | LoadDataSourceTypesAction
| SetDataSourceTypeSearchQueryAction; | SetDataSourceTypeSearchQueryAction
| LoadDataSourceAction
| UpdateNavIndexAction
| LoadDataSourceMetaAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>; type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult<void> {
}; };
} }
export function loadDataSource(id: number): ThunkResult<void> {
return async dispatch => {
const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`);
dispatch(dataSourceLoaded(dataSource));
dispatch(dataSourceMetaLoaded(pluginInfo));
dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
};
}
export function addDataSource(plugin: Plugin): ThunkResult<void> { export function addDataSource(plugin: Plugin): ThunkResult<void> {
return async (dispatch, getStore) => { return async (dispatch, getStore) => {
await dispatch(loadDataSources()); await dispatch(loadDataSources());

View File

@ -0,0 +1,109 @@
import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
import config from 'app/core/config';
export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
const navModel = {
img: pluginMeta.info.logos.large,
id: 'datasource-' + dataSource.id,
subTitle: `Type: ${pluginMeta.name}`,
url: '',
text: dataSource.name,
breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }],
children: [
{
active: false,
icon: 'fa fa-fw fa-sliders',
id: `datasource-settings-${dataSource.id}`,
text: 'Settings',
url: `datasources/edit/${dataSource.id}`,
},
],
};
if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) {
navModel.children.push({
active: false,
icon: 'fa fa-fw fa-th-large',
id: `datasource-dashboards-${dataSource.id}`,
text: 'Dashboards',
url: `datasources/edit/${dataSource.id}/dashboards`,
});
}
if (config.buildInfo.isEnterprise) {
navModel.children.push({
active: false,
icon: 'fa fa-fw fa-lock',
id: `datasource-permissions-${dataSource.id}`,
text: 'Permissions',
url: `datasources/edit/${dataSource.id}/permissions`,
});
}
return navModel;
}
export function getDataSourceLoadingNav(pageName: string): NavModel {
const main = buildNavModel(
{
access: '',
basicAuth: false,
database: '',
id: 1,
isDefault: false,
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
name: 'Loading',
orgId: 1,
password: '',
readOnly: false,
type: 'Loading',
typeLogoUrl: 'public/img/icn-datasource.svg',
url: '',
user: '',
},
{
id: '1',
name: '',
info: {
author: {
name: '',
url: '',
},
description: '',
links: [''],
logos: {
large: '',
small: '',
},
screenshots: '',
updated: '',
version: '',
},
includes: [{ type: '', name: '', path: '' }],
}
);
let node: NavModelItem;
// find active page
for (const child of main.children) {
if (child.id.indexOf(pageName) > 0) {
child.active = true;
node = child;
break;
}
}
return {
main: main,
node: node,
};
}
function hasDashboards(includes) {
return (
includes.filter(include => {
return include.type === 'dashboard';
}).length > 0
);
}

View File

@ -4,11 +4,13 @@ import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelec
const initialState: DataSourcesState = { const initialState: DataSourcesState = {
dataSources: [] as DataSource[], dataSources: [] as DataSource[],
dataSource: {} as DataSource,
layoutMode: LayoutModes.Grid, layoutMode: LayoutModes.Grid,
searchQuery: '', searchQuery: '',
dataSourcesCount: 0, dataSourcesCount: 0,
dataSourceTypes: [] as Plugin[], dataSourceTypes: [] as Plugin[],
dataSourceTypeSearchQuery: '', dataSourceTypeSearchQuery: '',
dataSourceMeta: {} as Plugin,
hasFetched: false, hasFetched: false,
}; };
@ -17,6 +19,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
case ActionTypes.LoadDataSources: case ActionTypes.LoadDataSources:
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length }; return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
case ActionTypes.LoadDataSource:
return { ...state, dataSource: action.payload };
case ActionTypes.SetDataSourcesSearchQuery: case ActionTypes.SetDataSourcesSearchQuery:
return { ...state, searchQuery: action.payload }; return { ...state, searchQuery: action.payload };
@ -28,6 +33,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
case ActionTypes.SetDataSourceTypeSearchQuery: case ActionTypes.SetDataSourceTypeSearchQuery:
return { ...state, dataSourceTypeSearchQuery: action.payload }; return { ...state, dataSourceTypeSearchQuery: action.payload };
case ActionTypes.LoadDataSourceMeta:
return { ...state, dataSourceMeta: action.payload };
} }
return state; return state;

View File

@ -1,3 +1,5 @@
import { DataSource } from '../../../types';
export const getDataSources = state => { export const getDataSources = state => {
const regex = new RegExp(state.searchQuery, 'i'); const regex = new RegExp(state.searchQuery, 'i');
@ -14,6 +16,13 @@ export const getDataSourceTypes = state => {
}); });
}; };
export const getDataSource = (state, dataSourceId): DataSource | null => {
if (state.dataSource.id === parseInt(dataSourceId, 10)) {
return state.dataSource;
}
return null;
};
export const getDataSourcesSearchQuery = state => state.searchQuery; export const getDataSourcesSearchQuery = state => state.searchQuery;
export const getDataSourcesLayoutMode = state => state.layoutMode; export const getDataSourcesLayoutMode = state => state.layoutMode;
export const getDataSourcesCount = state => state.dataSourcesCount; export const getDataSourcesCount = state => state.dataSourcesCount;

View File

@ -644,7 +644,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/> />
)} )}
{supportsTable && showingTable ? ( {supportsTable && showingTable ? (
<Table className="m-t-3" data={tableResult} loading={loading} onClickCell={this.onClickTableCell} /> <div className="panel-container">
<Table data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
</div>
) : null} ) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null} {supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
</main> </main>

View File

@ -96,11 +96,14 @@ describe('PromQueryField typeahead handling', () => {
it('returns label suggestions on label context but leaves out labels that already exist', () => { it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = shallow( const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{job="foo"}': ['bar', 'job'] }} /> <PromQueryField
{...defaultProps}
labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
/>
).instance() as PromQueryField; ).instance() as PromQueryField;
const value = Plain.deserialize('{job="foo",}'); const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({ const range = value.selection.merge({
anchorOffset: 11, anchorOffset: 36,
}); });
const valueWithSelection = value.change().select(range).value; const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({ const result = instance.getTypeahead({
@ -113,6 +116,33 @@ describe('PromQueryField typeahead handling', () => {
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
}); });
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{}': ['label'] }}
labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('{label!=}');
const range = value.selection.merge({ anchorOffset: 8 });
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '!=',
prefix: '',
wrapperClasses: ['context-labels'],
labelKey: 'label',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
label: 'Label values for "label"',
},
]);
});
it('returns a refresher on label context and unavailable metric', () => { it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow( const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} /> <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />

View File

@ -111,7 +111,7 @@ export function willApplySuggestion(
case 'context-label-values': { case 'context-label-values': {
// Always add quotes and remove existing ones instead // Always add quotes and remove existing ones instead
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) { if (!typeaheadText.match(/^(!?=~?"|")/)) {
suggestion = `"${suggestion}`; suggestion = `"${suggestion}`;
} }
if (getNextCharacter() !== '"') { if (getNextCharacter() !== '"') {
@ -421,7 +421,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const containsMetric = selector.indexOf('__name__=') > -1; const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) { if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values // Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) { if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey]; const labelValues = this.state.labelValues[selector][labelKey];
@ -571,10 +571,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
<button className="btn navbar-button navbar-button--tight">Log labels</button> <button className="btn navbar-button navbar-button--tight">Log labels</button>
</Cascader> </Cascader>
) : ( ) : (
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}> <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
<button className="btn navbar-button navbar-button--tight">Metrics</button> <button className="btn navbar-button navbar-button--tight">Metrics</button>
</Cascader> </Cascader>
)} )}
</div> </div>
<div className="prom-query-field-wrapper"> <div className="prom-query-field-wrapper">
<div className="slate-query-field-wrapper"> <div className="slate-query-field-wrapper">

View File

@ -228,7 +228,13 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
const offset = range.startOffset; const offset = range.startOffset;
const text = selection.anchorNode.textContent; const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset); let prefix = text.substr(0, offset);
if (cleanText) {
// Label values could have valid characters erased if `cleanText()` is
// blindly applied, which would undesirably interfere with suggestions
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
if (labelValueMatch) {
prefix = labelValueMatch[1];
} else if (cleanText) {
prefix = cleanText(prefix); prefix = cleanText(prefix);
} }

View File

@ -1,84 +1,55 @@
import _ from 'lodash';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import ReactTable from 'react-table';
import TableModel from 'app/core/table_model'; import TableModel from 'app/core/table_model';
const EMPTY_TABLE = new TableModel(); const EMPTY_TABLE = new TableModel();
interface TableProps { interface TableProps {
className?: string;
data: TableModel; data: TableModel;
loading: boolean; loading: boolean;
onClickCell?: (columnKey: string, rowValue: string) => void; onClickCell?: (columnKey: string, rowValue: string) => void;
} }
interface SFCCellProps { function prepareRows(rows, columnNames) {
columnIndex: number; return rows.map(cells => _.zipObject(columnNames, cells));
onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
rowIndex: number;
table: TableModel;
value: string;
} }
function Cell(props: SFCCellProps) { export default class Table extends PureComponent<TableProps> {
const { columnIndex, rowIndex, table, value, onClickCell } = props; getCellProps = (state, rowInfo, column) => {
const column = table.columns[columnIndex]; return {
if (column && column.filterable && onClickCell) { onClick: () => {
const onClick = event => { const columnKey = column.Header;
event.preventDefault(); const rowValue = rowInfo.row[columnKey];
onClickCell(column.text, value, columnIndex, rowIndex, table); this.props.onClickCell(columnKey, rowValue);
},
}; };
return ( };
<td>
<a className="link" onClick={onClick}>
{value}
</a>
</td>
);
}
return <td>{value}</td>;
}
export default class Table extends PureComponent<TableProps, {}> {
render() { render() {
const { className = '', data, loading, onClickCell } = this.props; const { data, loading } = this.props;
const tableModel = data || EMPTY_TABLE; const tableModel = data || EMPTY_TABLE;
if (!loading && data && data.rows.length === 0) { const columnNames = tableModel.columns.map(({ text }) => text);
return ( const columns = tableModel.columns.map(({ filterable, text }) => ({
<table className={`${className} filter-table`}> Header: text,
<thead> accessor: text,
<tr> show: text !== 'Time',
<th>Table</th> Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
</tr> }));
</thead> const noDataText = data ? 'The queries returned no data for a table.' : '';
<tbody>
<tr>
<td className="muted">The queries returned no data for a table.</td>
</tr>
</tbody>
</table>
);
}
return ( return (
<table className={`${className} filter-table`}> <ReactTable
<thead> columns={columns}
<tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr> data={tableModel.rows}
</thead> getTdProps={this.getCellProps}
<tbody> loading={loading}
{tableModel.rows.map((row, i) => ( minRows={0}
<tr key={i}> noDataText={noDataText}
{row.map((value, j) => ( resolveData={data => prepareRows(data, columnNames)}
<Cell showPagination={data}
key={j} />
columnIndex={j}
rowIndex={i}
value={String(value)}
table={data}
onClickCell={onClickCell}
/>
))}
</tr>
))}
</tbody>
</table>
); );
} }
} }

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