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:
- checkout
- run:
name: build, test and package grafana enterprise
command: './scripts/build/build_enterprise.sh'
name: prepare build tools
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:
name: sign packages
command: './scripts/build/sign_packages.sh'
@ -254,6 +263,53 @@ jobs:
paths:
- 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:
docker:
- image: circleci/python:2.7-stretch
@ -267,6 +323,19 @@ jobs:
name: deploy to s3
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:
docker:
- image: circleci/python:2.7-stretch
@ -313,7 +382,7 @@ workflows:
jobs:
- build-all:
filters: *filter-only-master
- build-enterprise:
- build-all-enterprise:
filters: *filter-only-master
- codespell:
filters: *filter-only-master
@ -356,13 +425,15 @@ workflows:
- gometalinter
- mysql-integration-test
- postgres-integration-test
- build-enterprise
- build-all-enterprise
filters: *filter-only-master
release:
jobs:
- build-all:
filters: *filter-only-release
- build-all-enterprise:
filters: *filter-only-release
- codespell:
filters: *filter-only-release
- gometalinter:
@ -385,6 +456,17 @@ workflows:
- mysql-integration-test
- postgres-integration-test
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:
requires:
- build-all

View File

@ -2,25 +2,37 @@
### 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)
* **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
* **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)
* **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
* 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)
* **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607)
* **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
* **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553)
* **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628)
* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641)
* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky)
* **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650)
* **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech)
* **HTTP API**: Fix /api/org/users so that query and limit querystrings works

View File

@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies
- Go 1.11
- Go (Latest Stable)
- NodeJS LTS
### 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`).
### 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`
2. Build the docker image `make build-docker-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
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/...
```
## 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
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
environment:
nodejs_version: "6"
nodejs_version: "8"
GOPATH: C:\gopath
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
server_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 |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
| maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |

View File

@ -128,7 +128,7 @@ Example json body:
In DingTalk PC Client:
1. Click "more" icon on left bottom of the panel.
1. Click "more" icon on upper right of the panel.
2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage".

View File

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

View File

@ -32,6 +32,7 @@ Name | Description
*Database* | Name of your MSSQL database.
*User* | Database user's login/username
*Password* | Database user's password
*Encrypt* | This option determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server, default `false` (Grafana v5.4+).
*Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+).
*Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+).
*Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+).
@ -72,8 +73,8 @@ Make sure the user does not get any unwanted privileges from the public role.
### Known Issues
MSSQL 2008 and 2008 R2 engine cannot handle login records when SSL encryption is not disabled. Due to this you may receive an `Login error: EOF` error when trying to create your datasource.
To fix MSSQL 2008 R2 issue, install MSSQL 2008 R2 Service Pack 2. To fix MSSQL 2008 issue, install Microsoft MSSQL 2008 Service Pack 3 and Cumulative update package 3 for MSSQL 2008 SP3.
If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect.
If possible, we recommend you to use the latest service pack available for optimal compatibility.
## Query Editor

View File

@ -206,6 +206,7 @@ datasources:
jsonData:
tokenUri: https://oauth2.googleapis.com/token
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
defaultProject: my-project-name
secureJsonData:
privateKey: |
-----BEGIN PRIVATE KEY-----

View File

@ -87,7 +87,7 @@ docker run \
## 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:
```bash
@ -103,6 +103,21 @@ docker run \
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
```bash

View File

@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple
## Dependencies
- [Go 1.11](https://golang.org/dl/)
- [Go (Latest Stable)](https://golang.org/dl/)
- [Git](https://git-scm.com/downloads)
- [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.

View File

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

View File

@ -160,6 +160,7 @@
"react-redux": "^5.0.7",
"react-select": "2.1.0",
"react-sizeme": "^2.3.6",
"react-table": "^6.8.6",
"react-transition-group": "^2.2.1",
"redux": "^4.0.0",
"redux-logger": "^3.0.6",

View File

@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() {
datasourceRoute.Get("/", Wrap(GetDataSources))
datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
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.Get("/:id", Wrap(GetDataSourceByID))
datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
}, 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/:pluginId/settings", Wrap(GetPluginSettingByID))
@ -251,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
}, 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)

View File

@ -2,6 +2,7 @@ package api
import (
"fmt"
"github.com/pkg/errors"
"time"
"github.com/grafana/grafana/pkg/api/pluginproxy"
@ -14,6 +15,20 @@ import (
const HeaderNameNoBackendCache = "X-Grafana-NoCache"
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"
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) {
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 {
c.JsonApiErr(500, "Unable to load datasource meta data", err)
return

View File

@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response {
result := make(dtos.DataSourceList, 0)
for _, ds := range query.Result {
dsItem := dtos.DataSourceListItemDTO{
Id: ds.Id,
OrgId: ds.OrgId,
Id: ds.Id,
Name: ds.Name,
Url: ds.Url,
Type: ds.Type,
@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response {
return JSON(200, &result)
}
func GetDataSourceByID(c *m.ReqContext) Response {
func GetDataSourceById(c *m.ReqContext) Response {
query := m.GetDataSourceByIdQuery{
Id: c.ParamsInt64(":id"),
OrgId: c.OrgId,
@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response {
return JSON(200, &dtos)
}
func DeleteDataSourceByID(c *m.ReqContext) Response {
func DeleteDataSourceById(c *m.ReqContext) Response {
id := c.ParamsInt64(":id")
if id <= 0 {
return Error(400, "Missing valid datasource id", nil)
}
ds, err := getRawDataSourceByID(id, c.OrgId)
ds, err := getRawDataSourceById(id, c.OrgId)
if err != nil {
return Error(400, "Failed to delete datasource", nil)
}
@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil
}
ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId)
ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
if err != nil {
return err
}
@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil
}
func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) {
func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) {
query := m.GetDataSourceByIdQuery{
Id: id,
OrgId: orgID,
@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response {
}
// 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}
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 {
return &AlertNotification{
Id: notification.Id,
Name: notification.Name,
Type: notification.Type,
IsDefault: notification.IsDefault,
Created: notification.Created,
Updated: notification.Updated,
Frequency: formatShort(notification.Frequency),
SendReminder: notification.SendReminder,
Settings: notification.Settings,
Id: notification.Id,
Name: notification.Name,
Type: notification.Type,
IsDefault: notification.IsDefault,
Created: notification.Created,
Updated: notification.Updated,
Frequency: formatShort(notification.Frequency),
SendReminder: notification.SendReminder,
DisableResolveMessage: notification.DisableResolveMessage,
Settings: notification.Settings,
}
}
type AlertNotification struct {
Id int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Settings *simplejson.Json `json:"settings"`
Id int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
SendReminder bool `json:"sendReminder"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Frequency string `json:"frequency"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Settings *simplejson.Json `json:"settings"`
}
type AlertTestCommand struct {
@ -100,11 +102,12 @@ type EvalMatch struct {
}
type NotificationTestCommand struct {
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings"`
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings"`
}
type PauseAlertCommand struct {

View File

@ -11,7 +11,7 @@ import (
"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)
if c.OrgId != 0 {
@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
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{})
@ -120,6 +133,10 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
panels := map[string]interface{}{}
for _, panel := range enabledPlugins.Panels {
if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels {
continue
}
panels[panel.Id] = map[string]interface{}{
"module": panel.Module,
"baseUrl": panel.BaseUrl,
@ -183,8 +200,8 @@ func getPanelSort(id string) int {
return sort
}
func GetFrontendSettings(c *m.ReqContext) {
settings, err := getFrontendSettingsMap(c)
func (hs *HTTPServer) GetFrontendSettings(c *m.ReqContext) {
settings, err := hs.getFrontendSettingsMap(c)
if err != nil {
c.JsonApiErr(400, "Failed to get frontend settings", err)
return

View File

@ -18,7 +18,7 @@ const (
)
func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
settings, err := getFrontendSettingsMap(c)
settings, err := hs.getFrontendSettingsMap(c)
if err != nil {
return nil, err
}

View File

@ -185,7 +185,9 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) {
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 {
Id int64 `json:"id"`
OrgId int64 `json:"-"`
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
Frequency time.Duration `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Id int64 `json:"id"`
OrgId int64 `json:"-"`
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Frequency time.Duration `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type CreateAlertNotificationCommand struct {
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
OrgId int64 `json:"-"`
Result *AlertNotification
}
type UpdateAlertNotificationCommand struct {
Id int64 `json:"id" binding:"Required"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings" binding:"Required"`
Id int64 `json:"id" binding:"Required"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings" binding:"Required"`
OrgId int64 `json:"-"`
Result *AlertNotification

View File

@ -30,6 +30,7 @@ var (
ErrDataSourceNameExists = errors.New("Data source with same name already exists")
ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource")
ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration.")
ErrDataSourceAccessDenied = errors.New("Data source access denied")
)
type DsAccess string
@ -167,6 +168,7 @@ type DeleteDataSourceByNameCommand struct {
type GetDataSourcesQuery struct {
OrgId int64
User *SignedInUser
Result []*DataSource
}
@ -187,6 +189,31 @@ type GetDataSourceByNameQuery struct {
}
// ---------------------
// EVENTS
type DataSourceCreatedEvent struct {
// Permissions
// ---------------------
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
GetIsDefault() bool
GetSendReminder() bool
GetDisableResolveMessage() bool
GetFrequency() time.Duration
}

View File

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

View File

@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) {
base := NewNotifierBase(model)
So(base.UploadImage, ShouldBeTrue)
})
Convey("default value should be false for backwards compatibility", func() {
base := NewNotifierBase(model)
So(base.DisableResolveMessage, ShouldBeFalse)
})
})
}

View File

@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default,
alert_notification.disable_resolve_message,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default,
alert_notification.disable_resolve_message,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
}
alertNotification := &m.AlertNotification{
OrgId: cmd.OrgId,
Name: cmd.Name,
Type: cmd.Type,
Settings: cmd.Settings,
SendReminder: cmd.SendReminder,
Frequency: frequency,
Created: time.Now(),
Updated: time.Now(),
IsDefault: cmd.IsDefault,
OrgId: cmd.OrgId,
Name: cmd.Name,
Type: cmd.Type,
Settings: cmd.Settings,
SendReminder: cmd.SendReminder,
DisableResolveMessage: cmd.DisableResolveMessage,
Frequency: frequency,
Created: time.Now(),
Updated: time.Now(),
IsDefault: cmd.IsDefault,
}
if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil {
@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Type = cmd.Type
current.IsDefault = cmd.IsDefault
current.SendReminder = cmd.SendReminder
current.DisableResolveMessage = cmd.DisableResolveMessage
if current.SendReminder {
if cmd.Frequency == "" {
@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Frequency = frequency
}
sess.UseBool("is_default", "send_reminder")
sess.UseBool("is_default", "send_reminder", "disable_resolve_message")
if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
return err

View File

@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(cmd.Result.OrgId, ShouldNotEqual, 0)
So(cmd.Result.Type, ShouldEqual, "email")
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
Convey("Cannot save Alert Notification with the same name", func() {
err = CreateAlertNotificationCommand(cmd)
@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Can update alert notification", func() {
newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
SendReminder: true,
Frequency: "60s",
Settings: simplejson.New(),
Id: cmd.Result.Id,
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
SendReminder: true,
DisableResolveMessage: true,
Frequency: "60s",
Settings: simplejson.New(),
Id: cmd.Result.Id,
}
err := UpdateAlertNotification(newCmd)
So(err, ShouldBeNil)
So(newCmd.Result.Name, ShouldEqual, "NewName")
So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
So(newCmd.Result.DisableResolveMessage, ShouldBeTrue)
})
Convey("Can update alert notification to disable sending of reminders", func() {

View File

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

View File

@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0",
}))
mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{
Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0",
}))
mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0]))

View File

@ -53,6 +53,7 @@ type SqlStore struct {
dbCfg DatabaseConfig
engine *xorm.Engine
log log.Logger
Dialect migrator.Dialect
skipEnsureAdmin bool
}
@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error {
}
ss.engine = engine
ss.Dialect = migrator.NewDialect(ss.engine)
// temporarily still set global var
x = engine
dialect = migrator.NewDialect(x)
dialect = ss.Dialect
migrator := migrator.NewMigrator(x)
migrations.AddMigrations(migrator)
@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore {
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 {
t.Fatalf("Failed to clean test db %v", err)
}

View File

@ -213,6 +213,8 @@ type Cfg struct {
TempDataLifetime time.Duration
MetricsEndpointEnabled bool
EnableAlphaPanels bool
}
type CommandLineArgs struct {
@ -694,6 +696,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
explore := iniFile.Section("explore")
ExploreEnabled = explore.Key("enabled").MustBool(false)
panels := iniFile.Section("panels")
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
cfg.readSessionConfig()
cfg.readSmtpSettings()
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) {
result := &tsdb.Response{
results := &tsdb.Response{
Results: make(map[string]*tsdb.QueryResult),
}
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
eg, ectx := errgroup.WithContext(ctx)
@ -102,10 +103,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
RefId := queryContext.Queries[i].RefId
query, err := parseQuery(queryContext.Queries[i].Model)
if err != nil {
result.Results[RefId] = &tsdb.QueryResult{
results.Results[RefId] = &tsdb.QueryResult{
Error: err,
}
return result, nil
return results, nil
}
query.RefId = RefId
@ -118,10 +119,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
}
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"),
}
return result, nil
return results, nil
}
eg.Go(func() error {
@ -130,12 +131,13 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
return err
}
if err != nil {
result.Results[query.RefId] = &tsdb.QueryResult{
resultChan <- &tsdb.QueryResult{
RefId: query.RefId,
Error: err,
}
return nil
}
result.Results[queryRes.RefId] = queryRes
resultChan <- queryRes
return nil
})
}
@ -149,10 +151,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
return err
}
for _, queryRes := range queryResponses {
result.Results[queryRes.RefId] = queryRes
if err != nil {
result.Results[queryRes.RefId].Error = err
queryRes.Error = err
}
resultChan <- queryRes
}
return nil
})
@ -162,8 +164,12 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
if err := eg.Wait(); err != nil {
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) {

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
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
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",
"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",
"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-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)
for _, region := range regions {
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]
return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
server,
port,
datasource.Database,
datasource.User,
password,
)
if encrypt != "false" {
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
}
return connStr
}
type mssqlRowTransformer struct {

View File

@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"strconv"
"strings"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/core"
@ -20,10 +21,14 @@ func init() {
func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
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",
datasource.User,
datasource.Password,
"tcp",
protocol,
datasource.Url,
datasource.Database,
)

View File

@ -26,8 +26,12 @@ _.move = (array, fromIndex, toIndex) => {
return array;
};
import { coreModule, registerAngularDirectives } from './core/core';
import { setupAngularRoutes } from './routes/routes';
import { coreModule, angularModules } from 'app/core/core_module';
import { registerAngularDirectives } from 'app/core/core';
import { setupAngularRoutes } from 'app/routes/routes';
import 'app/routes/GrafanaCtrl';
import 'app/features/all';
// import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
@ -109,39 +113,26 @@ export class GrafanaApp {
'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
this.useModule(coreModule);
_.each(angularModules, m => {
this.useModule(m);
});
// register react angular wrappers
coreModule.config(setupAngularRoutes);
registerAngularDirectives();
const preBootRequires = [import('app/features/all')];
// disable tool tip animation
$.fn.tooltip.defaults.animation = false;
Promise.all(preBootRequires)
.then(() => {
// disable tool tip animation
$.fn.tooltip.defaults.animation = false;
// 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);
// bootstrap the app
angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
_.each(this.preBootModules, module => {
_.extend(module, this.registerFunctions);
});
this.preBootModules = null;
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import _ from 'lodash';
import { PanelPlugin } from 'app/types/plugins';
export interface BuildInfo {
version: string;
@ -9,7 +10,7 @@ export interface BuildInfo {
export class Settings {
datasources: any;
panels: any;
panels: PanelPlugin[];
appSubUrl: string;
windowTitlePrefix: string;
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 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/ng_react';
import { grafanaAppDirective } from './components/grafana_app';
import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover';
import { navbarDirective } from './components/navbar/navbar';
@ -60,7 +59,6 @@ export {
registerAngularDirectives,
arrayJoin,
coreModule,
grafanaAppDirective,
navbarDirective,
searchDirective,
liveSrv,

View File

@ -1,2 +1,18 @@
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';
/** @ngInject */
export function dashClass() {
function dashClass($timeout) {
return {
link: ($scope, elem) => {
$scope.onAppEvent('panel-fullscreen-enter', () => {
elem.toggleClass('panel-in-fullscreen', true);
$scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
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', false);
});
elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
$scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
if (newValue) {

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl {
defaults: any = {
type: 'email',
sendReminder: false,
disableResolveMessage: false,
frequency: '15m',
settings: {
httpMethod: 'POST',

View File

@ -21,21 +21,28 @@
<gf-form-switch
class="gf-form"
label="Send on all alerts"
label-class="width-12"
label-class="width-14"
checked="ctrl.model.isDefault"
tooltip="Use this notification for all alerts">
</gf-form-switch>
<gf-form-switch
class="gf-form"
label="Include image"
label-class="width-12"
label-class="width-14"
checked="ctrl.model.settings.uploadImage"
tooltip="Captures an image and include it in the notification">
</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
class="gf-form"
label="Send reminders"
label-class="width-12"
label-class="width-14"
checked="ctrl.model.sendReminder"
tooltip="Send additional notifications for triggered alerts">
</gf-form-switch>

View File

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

View File

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

View File

@ -200,6 +200,43 @@ export class DashboardModel {
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) {
if (!data) {
data = {};

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelModel } from '../panel_model';
import { PanelContainer } from './PanelContainer';
import { DashboardModel } from '../dashboard_model';
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
@ -11,7 +11,7 @@ import Highlighter from 'react-highlight-words';
export interface AddPanelPanelProps {
panel: PanelModel;
getPanelContainer: () => PanelContainer;
dashboard: DashboardModel;
}
export interface AddPanelPanelState {
@ -93,8 +93,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
}
onAddPanel = panelPluginInfo => {
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
const newPanel: any = {
@ -123,9 +122,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
handleCloseAddPanel(evt) {
evt.preventDefault();
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
dashboard.removePanel(dashboard.panels[0]);
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
}
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 { DashboardPanel } from './DashboardPanel';
import { DashboardModel } from '../dashboard_model';
import { PanelContainer } from './PanelContainer';
import { PanelModel } from '../panel_model';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
@ -60,18 +59,15 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps {
getPanelContainer: () => PanelContainer;
dashboard: DashboardModel;
}
export class DashboardGrid extends React.Component<DashboardGridProps, any> {
gridToPanelMap: any;
panelContainer: PanelContainer;
dashboard: DashboardModel;
panelMap: { [id: string]: PanelModel };
constructor(props) {
super(props);
this.panelContainer = this.props.getPanelContainer();
this.onLayoutChange = this.onLayoutChange.bind(this);
this.onResize = this.onResize.bind(this);
this.onResizeStop = this.onResizeStop.bind(this);
@ -81,20 +77,21 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.state = { animated: false };
// subscribe to dashboard events
this.dashboard = this.panelContainer.getDashboard();
this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this));
this.dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
this.dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
const dashboard = this.props.dashboard;
dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this));
dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
dashboard.on('panel-type-changed', this.triggerForceUpdate.bind(this));
}
buildLayout() {
const layout = [];
this.panelMap = {};
for (const panel of this.dashboard.panels) {
for (const panel of this.props.dashboard.panels) {
const stringId = panel.id.toString();
this.panelMap[stringId] = panel;
@ -129,7 +126,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.panelMap[newPos.i].updateGridPos(newPos);
}
this.dashboard.sortPanelsByGridPos();
this.props.dashboard.sortPanelsByGridPos();
}
triggerForceUpdate() {
@ -137,11 +134,15 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
}
onWidthChange() {
for (const panel of this.dashboard.panels) {
for (const panel of this.props.dashboard.panels) {
panel.resizeDone();
}
}
onViewModeChanged(payload) {
this.setState({ animated: !payload.fullscreen });
}
updateGridPos(item, layout) {
this.panelMap[item.i].updateGridPos(item);
@ -165,21 +166,18 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
componentDidMount() {
setTimeout(() => {
this.setState(() => {
return { animated: true };
});
this.setState({ animated: true });
});
}
renderPanels() {
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 });
panelElements.push(
/** panel-id is set for html bookmarks */
<div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id.toString()}`}>
<DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
<div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
<DashboardPanel panel={panel} dashboard={this.props.dashboard} panelType={panel.type} />
</div>
);
}
@ -192,8 +190,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
<SizedReactLayoutGrid
className={classNames({ layout: true, animated: this.state.animated })}
layout={this.buildLayout()}
isResizable={this.dashboard.meta.canEdit}
isDraggable={this.dashboard.meta.canEdit}
isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.props.dashboard.meta.canEdit}
onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop}

View File

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

View File

@ -1,54 +1,161 @@
import React from 'react';
import {PanelModel} from '../panel_model';
import {PanelContainer} from './PanelContainer';
import {AttachedPanel} from './PanelLoader';
import {DashboardRow} from './DashboardRow';
import {AddPanelPanel} from './AddPanelPanel';
import config from 'app/core/config';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
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;
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;
attachedPanel: AttachedPanel;
angularPanel: AngularComponent;
pluginInfo: any;
specialPanels = {};
constructor(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() {
if (!this.element) {
isSpecial() {
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;
}
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
const loader = panelContainer.getPanelLoader();
this.attachedPanel = loader.load(this.element, this.props.panel, dashboard);
// handle plugin loading & changing of plugin type
if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
this.pluginInfo = config.panels[this.props.panel.type];
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() {
if (this.attachedPanel) {
this.attachedPanel.destroy();
}
this.cleanUpAngularPanel();
}
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() {
// special handling for rows
if (this.props.panel.type === 'row') {
return <DashboardRow panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />;
if (this.isSpecial()) {
return this.specialPanels[this.props.panel.type]();
}
if (this.props.panel.type === 'add-panel') {
return <AddPanelPanel panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />;
if (!this.state.pluginExports) {
return null;
}
return (
<div ref={element => this.element = element} className="panel-height-helper" />
);
if (this.state.pluginExports.PanelComponent) {
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 classNames from 'classnames';
import { PanelModel } from '../panel_model';
import { PanelContainer } from './PanelContainer';
import { DashboardModel } from '../dashboard_model';
import templateSrv from 'app/features/templating/template_srv';
import appEvents from 'app/core/app_events';
export interface DashboardRowProps {
panel: PanelModel;
getPanelContainer: () => PanelContainer;
dashboard: DashboardModel;
}
export class DashboardRow extends React.Component<DashboardRowProps, any> {
dashboard: any;
panelContainer: any;
constructor(props) {
super(props);
@ -21,9 +18,6 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed,
};
this.panelContainer = this.props.getPanelContainer();
this.dashboard = this.panelContainer.getDashboard();
this.toggle = this.toggle.bind(this);
this.openSettings = this.openSettings.bind(this);
this.delete = this.delete.bind(this);
@ -31,7 +25,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
}
toggle() {
this.dashboard.toggleRow(this.props.panel);
this.props.dashboard.toggleRow(this.props.panel);
this.setState(prevState => {
return { collapsed: !prevState.collapsed };
@ -39,7 +33,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
}
update() {
this.dashboard.processRepeats();
this.props.dashboard.processRepeats();
this.forceUpdate();
}
@ -61,14 +55,10 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
altActionText: 'Delete row only',
icon: 'fa-trash',
onConfirm: () => {
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
dashboard.removeRow(this.props.panel, true);
this.props.dashboard.removeRow(this.props.panel, true);
},
onAltAction: () => {
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
dashboard.removeRow(this.props.panel, false);
this.props.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 count = this.props.panel.panels ? this.props.panel.panels.length : 0;
const panels = count === 1 ? 'panel' : 'panels';
const canEdit = this.dashboard.meta.canEdit === true;
const canEdit = this.props.dashboard.meta.canEdit === true;
return (
<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) {
delete search.fullscreen;
delete search.edit;
delete search.tab;
delete search.panelId;
}
this.$location.search(search);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { DashboardRow } from '../dashgrid/DashboardRow';
import { PanelModel } from '../panel_model';
describe('DashboardRow', () => {
let wrapper, panel, getPanelContainer, dashboardMock;
let wrapper, panel, dashboardMock;
beforeEach(() => {
dashboardMock = {
@ -14,13 +14,8 @@ describe('DashboardRow', () => {
},
};
getPanelContainer = jest.fn().mockReturnValue({
getDashboard: jest.fn().mockReturnValue(dashboardMock),
getPanelLoader: jest.fn(),
});
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', () => {
@ -41,14 +36,14 @@ describe('DashboardRow', () => {
it('should not show row drag handle when cannot edit', () => {
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);
});
it('should have zero actions when cannot edit', () => {
dashboardMock.meta.canEdit = 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);
});
});

View File

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

View File

@ -1,7 +1,8 @@
import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
import { getBackendSrv } from 'app/core/services/backend_srv';
import appEvents from 'app/core/app_events';
import { loadPluginDashboards } from '../../plugins/state/actions';
import {
DashboardAcl,
DashboardAclDTO,
@ -113,3 +114,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
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;
/** @ngInject */
constructor(private $rootScope, private variableSrv, private $location) {
constructor(private variableSrv, private $location) {
this.annotations = this.dashboard.templating.list;
this.variables = this.variableSrv.variables;
}
annotationStateChanged() {
this.$rootScope.$broadcast('refresh');
this.dashboard.startRefresh();
}
variableUpdated(variable) {

View File

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

View File

@ -5,7 +5,7 @@
<div class="gf-form">
<label class="gf-form-label width-10">Timezone</label>
<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>

View File

@ -31,9 +31,10 @@ export class TimePickerCtrl {
$rootScope.onAppEvent('shift-time-forward', () => 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);
this.dashboard.on('refresh', this.onRefresh.bind(this), $scope);
// init options
this.panel = this.dashboard.timepicker;
_.defaults(this.panel, TimePickerCtrl.defaults);

View File

@ -1,6 +1,7 @@
import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from './dashboard_model';
// represents the transient view state
@ -10,12 +11,11 @@ export class DashboardViewState {
panelScopes: any;
$scope: any;
dashboard: DashboardModel;
editStateChanged: any;
fullscreenPanel: any;
oldTimeRange: any;
/** @ngInject */
constructor($scope, private $location, private $timeout, private $rootScope) {
constructor($scope, private $location, private $timeout) {
const self = this;
self.state = {};
self.panelScopes = [];
@ -33,10 +33,6 @@ export class DashboardViewState {
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
// don't want url changes like adding orgId to add browser history
$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);
this.dashboard.meta.fullscreen = this.state.fullscreen;
@ -124,110 +117,59 @@ export class DashboardViewState {
}
syncState() {
if (this.panelScopes.length === 0) {
return;
}
if (this.dashboard.meta.fullscreen) {
const panelScope = this.getPanelScope(this.state.panelId);
if (!panelScope) {
const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panel) {
return;
}
if (this.fullscreenPanel) {
// if already fullscreen
if (this.fullscreenPanel === panelScope && this.editStateChanged === false) {
return;
} else {
this.leaveFullscreen(false);
}
}
if (!panelScope.ctrl.editModeInitiated) {
panelScope.ctrl.initEditMode();
}
if (!panelScope.ctrl.fullscreen) {
this.enterFullscreen(panelScope);
if (!panel.fullscreen) {
this.enterFullscreen(panel);
} else {
// already in fullscreen view just update the view mode
this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
}
} else if (this.fullscreenPanel) {
this.leaveFullscreen(true);
this.leaveFullscreen();
}
}
getPanelScope(id) {
return _.find(this.panelScopes, panelScope => {
return panelScope.ctrl.panel.id === id;
});
}
leaveFullscreen() {
const panel = this.fullscreenPanel;
leaveFullscreen(render) {
const self = this;
const ctrl = self.fullscreenPanel.ctrl;
this.dashboard.setViewMode(panel, false, false);
ctrl.editMode = false;
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;
}
delete this.fullscreenPanel;
this.$timeout(() => {
if (self.oldTimeRange !== ctrl.range) {
self.$rootScope.$broadcast('refresh');
appEvents.emit('dash-scroll', { restore: true });
if (this.oldTimeRange !== this.dashboard.time) {
this.dashboard.startRefresh();
} else {
self.$rootScope.$broadcast('render');
this.dashboard.render();
}
delete self.fullscreenPanel;
});
return true;
}
enterFullscreen(panelScope) {
const ctrl = panelScope.ctrl;
enterFullscreen(panel) {
const isEditing = this.state.edit && this.dashboard.meta.canEdit;
ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit;
ctrl.fullscreen = true;
this.oldTimeRange = ctrl.range;
this.fullscreenPanel = panelScope;
this.oldTimeRange = this.dashboard.time;
this.fullscreenPanel = panel;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode);
this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
}
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();
});
this.dashboard.setViewMode(panel, true, isEditing);
}
}
/** @ngInject */
export function dashboardViewStateSrv($location, $timeout, $rootScope) {
export function dashboardViewStateSrv($location, $timeout) {
return {
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 { NavModel, Plugin } from 'app/types';
import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
import { updateLocation } from '../../core/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getDataSourceTypes } from './state/selectors';
@ -13,7 +12,6 @@ export interface Props {
dataSourceTypes: Plugin[];
addDataSource: typeof addDataSource;
loadDataSourceTypes: typeof loadDataSourceTypes;
updateLocation: typeof updateLocation;
dataSourceTypeSearchQuery: string;
setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
}
@ -81,7 +79,6 @@ function mapStateToProps(state) {
const mapDispatchToProps = {
addDataSource,
loadDataSourceTypes,
updateLocation,
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 { getBackendSrv } from '../../../core/services/backend_srv';
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 { buildNavModel } from './navModel';
export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES',
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
LoadDataSource = 'LOAD_DATA_SOURCE',
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction {
payload: string;
}
export interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
payload: DataSource;
}
export interface LoadDataSourceMetaAction {
type: ActionTypes.LoadDataSourceMeta;
payload: Plugin;
}
const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources,
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 => ({
type: ActionTypes.LoadDataSourceTypes,
payload: dataSourceTypes,
@ -69,7 +92,10 @@ export type Action =
| SetDataSourcesLayoutModeAction
| UpdateLocationAction
| LoadDataSourceTypesAction
| SetDataSourceTypeSearchQueryAction;
| SetDataSourceTypeSearchQueryAction
| LoadDataSourceAction
| UpdateNavIndexAction
| LoadDataSourceMetaAction;
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> {
return async (dispatch, getStore) => {
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 = {
dataSources: [] as DataSource[],
dataSource: {} as DataSource,
layoutMode: LayoutModes.Grid,
searchQuery: '',
dataSourcesCount: 0,
dataSourceTypes: [] as Plugin[],
dataSourceTypeSearchQuery: '',
dataSourceMeta: {} as Plugin,
hasFetched: false,
};
@ -17,6 +19,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
case ActionTypes.LoadDataSources:
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
case ActionTypes.LoadDataSource:
return { ...state, dataSource: action.payload };
case ActionTypes.SetDataSourcesSearchQuery:
return { ...state, searchQuery: action.payload };
@ -28,6 +33,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
case ActionTypes.SetDataSourceTypeSearchQuery:
return { ...state, dataSourceTypeSearchQuery: action.payload };
case ActionTypes.LoadDataSourceMeta:
return { ...state, dataSourceMeta: action.payload };
}
return state;

View File

@ -1,3 +1,5 @@
import { DataSource } from '../../../types';
export const getDataSources = state => {
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 getDataSourcesLayoutMode = state => state.layoutMode;
export const getDataSourcesCount = state => state.dataSourcesCount;

View File

@ -644,7 +644,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/>
)}
{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}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
</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', () => {
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;
const value = Plain.deserialize('{job="foo",}');
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({
anchorOffset: 11,
anchorOffset: 36,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
@ -113,6 +116,33 @@ describe('PromQueryField typeahead handling', () => {
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', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />

View File

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

View File

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

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