diff --git a/.circleci/config.yml b/.circleci/config.yml index a5497e6c7e8..87cd502ef18 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ccafcf3af2e..db5457147f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1179385d10c..5882ea8a6a3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md new file mode 100644 index 00000000000..f3d2adbd71a --- /dev/null +++ b/UPGRADING_DEPENDENCIES.md @@ -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. \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index 52f23162033..4bbd3668e19 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -5,7 +5,7 @@ os: Windows Server 2012 R2 clone_folder: c:\gopath\src\github.com\grafana\grafana environment: - nodejs_version: "6" + nodejs_version: "8" GOPATH: C:\gopath GOVERSION: 1.11 diff --git a/conf/defaults.ini b/conf/defaults.ini index eb8debc0094..750f06f2f6a 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index e4c78d692b3..8916b2bf6e3 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -166,6 +166,7 @@ Since not all datasources have the same configuration settings we only have the | tsdbVersion | string | OpenTSDB | Version | | tsdbResolution | string | OpenTSDB | Resolution | | sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' | +| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' | | postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 | | timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension | | maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) | diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index a5b7f4264e0..307af1ee15e 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -128,7 +128,7 @@ Example json body: In DingTalk PC Client: -1. Click "more" icon on left bottom of the panel. +1. Click "more" icon on upper right of the panel. 2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage". diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 7adc6ebe4fb..be36d108475 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -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": "*" } diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md index 4a3478b161a..cd191f14273 100644 --- a/docs/sources/features/datasources/mssql.md +++ b/docs/sources/features/datasources/mssql.md @@ -32,6 +32,7 @@ Name | Description *Database* | Name of your MSSQL database. *User* | Database user's login/username *Password* | Database user's password +*Encrypt* | This option determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server, default `false` (Grafana v5.4+). *Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+). *Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+). *Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+). @@ -72,8 +73,8 @@ Make sure the user does not get any unwanted privileges from the public role. ### Known Issues -MSSQL 2008 and 2008 R2 engine cannot handle login records when SSL encryption is not disabled. Due to this you may receive an `Login error: EOF` error when trying to create your datasource. -To fix MSSQL 2008 R2 issue, install MSSQL 2008 R2 Service Pack 2. To fix MSSQL 2008 issue, install Microsoft MSSQL 2008 Service Pack 3 and Cumulative update package 3 for MSSQL 2008 SP3. +If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect. +If possible, we recommend you to use the latest service pack available for optimal compatibility. ## Query Editor diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index cea86e96faf..103a58587b1 100644 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -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----- diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index ba0d6199ba4..52353ede8c2 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -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=;` + +```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 diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index ea75b9797e8..eed05f05fa6 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -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. diff --git a/latest.json b/latest.json index 3b5a9e0c227..4355e9a64b7 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "5.3.0", - "testing": "5.3.0" + "stable": "5.3.1", + "testing": "5.3.1" } diff --git a/package.json b/package.json index 0517c0d5ba5..b4c70c5b3a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/api/api.go b/pkg/api/api.go index 5c5596d5da2..f1fe940e416 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index eddfb884f8f..3bb2f236129 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -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 diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 23dbb221d71..e7614614076 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -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 { diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index 697d0a35a08..c037831f341 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { return &AlertNotification{ - Id: notification.Id, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Created: notification.Created, - Updated: notification.Updated, - Frequency: formatShort(notification.Frequency), - SendReminder: notification.SendReminder, - Settings: notification.Settings, + Id: notification.Id, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Created: notification.Created, + Updated: notification.Updated, + Frequency: formatShort(notification.Frequency), + SendReminder: notification.SendReminder, + DisableResolveMessage: notification.DisableResolveMessage, + Settings: notification.Settings, } } type AlertNotification struct { - Id int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsDefault bool `json:"isDefault"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Settings *simplejson.Json `json:"settings"` + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsDefault bool `json:"isDefault"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Settings *simplejson.Json `json:"settings"` } type AlertTestCommand struct { @@ -100,11 +102,12 @@ type EvalMatch struct { } type NotificationTestCommand struct { - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Settings *simplejson.Json `json:"settings"` } type PauseAlertCommand struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index a58be38781e..1de65c7b2ce 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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 diff --git a/pkg/api/index.go b/pkg/api/index.go index 9f867d51cad..e61620f9586 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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 } diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 43f45f900d9..4c71ab3cd5f 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -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 + } } } diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index 2128b469fa4..e0fd12937ed 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -23,38 +23,41 @@ var ( ) type AlertNotification struct { - Id int64 `json:"id"` - OrgId int64 `json:"-"` - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency time.Duration `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Id int64 `json:"id"` + OrgId int64 `json:"-"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency time.Duration `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } type CreateAlertNotificationCommand struct { - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` OrgId int64 `json:"-"` Result *AlertNotification } type UpdateAlertNotificationCommand struct { - Id int64 `json:"id" binding:"Required"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings" binding:"Required"` + Id int64 `json:"id" binding:"Required"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings" binding:"Required"` OrgId int64 `json:"-"` Result *AlertNotification diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index c730622512f..b71d17ec0d1 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -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 } diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 96294f0624f..040d0991861 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -27,6 +27,7 @@ type Notifier interface { GetNotifierId() int64 GetIsDefault() bool GetSendReminder() bool + GetDisableResolveMessage() bool GetFrequency() time.Duration } diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index fbade2eccac..d141d6cd257 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" ) @@ -15,13 +14,14 @@ const ( ) type NotifierBase struct { - Name string - Type string - Id int64 - IsDeault bool - UploadImage bool - SendReminder bool - Frequency time.Duration + Name string + Type string + Id int64 + IsDeault bool + UploadImage bool + SendReminder bool + DisableResolveMessage bool + Frequency time.Duration log log.Logger } @@ -34,14 +34,15 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } return NotifierBase{ - Id: model.Id, - Name: model.Name, - IsDeault: model.IsDefault, - Type: model.Type, - UploadImage: uploadImage, - SendReminder: model.SendReminder, - Frequency: model.Frequency, - log: log.New("alerting.notifier." + model.Name), + Id: model.Id, + Name: model.Name, + IsDeault: model.IsDefault, + Type: model.Type, + UploadImage: uploadImage, + SendReminder: model.SendReminder, + DisableResolveMessage: model.DisableResolveMessage, + Frequency: model.Frequency, + log: log.New("alerting.notifier." + model.Name), } } @@ -83,6 +84,11 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC } } + // Do not notify when state is OK if DisableResolveMessage is set to true + if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage { + return false + } + return true } @@ -106,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool { return n.SendReminder } +func (n *NotifierBase) GetDisableResolveMessage() bool { + return n.DisableResolveMessage +} + func (n *NotifierBase) GetFrequency() time.Duration { return n.Frequency } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 5e46d3ad72e..5062828cb4f 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) { base := NewNotifierBase(model) So(base.UploadImage, ShouldBeTrue) }) + + Convey("default value should be false for backwards compatibility", func() { + base := NewNotifierBase(model) + So(base.DisableResolveMessage, ShouldBeFalse) + }) }) } diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index daaef945b96..afe6269510f 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error } alertNotification := &m.AlertNotification{ - OrgId: cmd.OrgId, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - SendReminder: cmd.SendReminder, - Frequency: frequency, - Created: time.Now(), - Updated: time.Now(), - IsDefault: cmd.IsDefault, + OrgId: cmd.OrgId, + Name: cmd.Name, + Type: cmd.Type, + Settings: cmd.Settings, + SendReminder: cmd.SendReminder, + DisableResolveMessage: cmd.DisableResolveMessage, + Frequency: frequency, + Created: time.Now(), + Updated: time.Now(), + IsDefault: cmd.IsDefault, } if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil { @@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Type = cmd.Type current.IsDefault = cmd.IsDefault current.SendReminder = cmd.SendReminder + current.DisableResolveMessage = cmd.DisableResolveMessage if current.SendReminder { if cmd.Frequency == "" { @@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Frequency = frequency } - sess.UseBool("is_default", "send_reminder") + sess.UseBool("is_default", "send_reminder", "disable_resolve_message") if affected, err := sess.ID(cmd.Id).Update(current); err != nil { return err diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index ed682bae5c6..629a6292eb5 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { So(cmd.Result.OrgId, ShouldNotEqual, 0) So(cmd.Result.Type, ShouldEqual, "email") So(cmd.Result.Frequency, ShouldEqual, 10*time.Second) + So(cmd.Result.DisableResolveMessage, ShouldBeFalse) Convey("Cannot save Alert Notification with the same name", func() { err = CreateAlertNotificationCommand(cmd) @@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) { Convey("Can update alert notification", func() { newCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgId: cmd.Result.OrgId, - SendReminder: true, - Frequency: "60s", - Settings: simplejson.New(), - Id: cmd.Result.Id, + Name: "NewName", + Type: "webhook", + OrgId: cmd.Result.OrgId, + SendReminder: true, + DisableResolveMessage: true, + Frequency: "60s", + Settings: simplejson.New(), + Id: cmd.Result.Id, } err := UpdateAlertNotification(newCmd) So(err, ShouldBeNil) So(newCmd.Result.Name, ShouldEqual, "NewName") So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second) + So(newCmd.Result.DisableResolveMessage, ShouldBeTrue) }) Convey("Can update alert notification to disable sending of reminders", func() { diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 00d520bcfc6..7f70e5c25fc 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -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 } diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index cadcccf6c95..198a47b50ff 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) { mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{ Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0", })) + mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{ + Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0", + })) mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0])) diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 67f1bd7f75a..f904b44c3c8 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -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) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 58901e55c6b..16158ded002 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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() diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 61bbc04394a..437457df52a 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -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) { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ee9d9583c4e..b74af76f09a 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -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}) diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 72e57d03fa0..469d6baa5de 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -52,13 +52,18 @@ func generateConnectionString(datasource *models.DataSource) string { } server, port := hostParts[0], hostParts[1] - return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", + encrypt := datasource.JsonData.Get("encrypt").MustString("false") + connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", server, port, datasource.Database, datasource.User, password, ) + if encrypt != "false" { + connStr += fmt.Sprintf("encrypt=%s;", encrypt) + } + return connStr } type mssqlRowTransformer struct { diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 645f6b49bbb..35b03e489a0 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -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, ) diff --git a/public/app/app.ts b/public/app/app.ts index 298bf5609cd..9647fbe5416 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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; + }); } } diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index a60a7dd4af6..71cc937ddfa 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -18,6 +18,10 @@ export interface Props { } class AddPermissions extends Component { + static defaultProps = { + showPermissionLevels: true, + }; + constructor(props) { super(props); this.state = this.getCleanState(); diff --git a/public/app/core/components/Picker/DescriptionPicker.tsx b/public/app/core/components/Picker/DescriptionPicker.tsx index 4c39dcd8a79..a917e3977f5 100644 --- a/public/app/core/components/Picker/DescriptionPicker.tsx +++ b/public/app/core/components/Picker/DescriptionPicker.tsx @@ -22,10 +22,6 @@ export interface Props { const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value); class DescriptionPicker extends Component { - constructor(props) { - super(props); - } - render() { const { optionsWithDesc, onSelected, disabled, className, value } = this.props; const selectedOption = getSelectedOption(optionsWithDesc, value); diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index 6181d020471..2cab966ed46 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -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); diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts index bd355817f92..49931ecaac4 100644 --- a/public/app/core/components/scroll/scroll.ts +++ b/public/app/core/components/scroll/scroll.ts @@ -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; } diff --git a/public/app/core/components/sql_part/sql_part_editor.ts b/public/app/core/components/sql_part/sql_part_editor.ts index 8097dddeb3b..1d29c577560 100644 --- a/public/app/core/components/sql_part/sql_part_editor.ts +++ b/public/app/core/components/sql_part/sql_part_editor.ts @@ -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(); diff --git a/public/app/core/config.ts b/public/app/core/config.ts index bf5abe37d7f..1473f8a91f8 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -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; diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index 2642c5e400a..00981156614 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -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; diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 9a398a5ae5a..257a2077c97 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -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, diff --git a/public/app/core/core_module.ts b/public/app/core/core_module.ts index f6c30e6cf15..c8401975c18 100644 --- a/public/app/core/core_module.ts +++ b/public/app/core/core_module.ts @@ -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; diff --git a/public/app/core/directives/dash_class.ts b/public/app/core/directives/dash_class.ts index 224bc2c772d..37124eb7d4b 100644 --- a/public/app/core/directives/dash_class.ts +++ b/public/app/core/directives/dash_class.ts @@ -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) { diff --git a/public/app/core/directives/metric_segment.ts b/public/app/core/directives/metric_segment.ts index de904e95fc6..85576dcffee 100644 --- a/public/app/core/directives/metric_segment.ts +++ b/public/app/core/directives/metric_segment.ts @@ -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 = ' { 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(); diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 2089cfe9f59..7c7dffd04b9 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -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, }; } diff --git a/public/app/core/services/dynamic_directive_srv.ts b/public/app/core/services/dynamic_directive_srv.ts index ccd86856755..9b7ede59853 100644 --- a/public/app/core/services/dynamic_directive_srv.ts +++ b/public/app/core/services/dynamic_directive_srv.ts @@ -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) { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index d8dfc958dd4..f43dc96cd37 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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', () => { diff --git a/public/app/features/alerting/NotificationsEditCtrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts index 315a9a619a1..2607121bb0e 100644 --- a/public/app/features/alerting/NotificationsEditCtrl.ts +++ b/public/app/features/alerting/NotificationsEditCtrl.ts @@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl { defaults: any = { type: 'email', sendReminder: false, + disableResolveMessage: false, frequency: '15m', settings: { httpMethod: 'POST', diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index 7b198736b83..b2cd2f21e4d 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -21,21 +21,28 @@ + + diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index f75743513f1..5ec4e5e3929 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -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'; diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index d61eb08d5b9..c34b9ddaff2 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -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); } } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 818765124bf..65a234a2b94 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -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 = {}; diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx index a26a0401d56..68cee112f42 100644 --- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx +++ b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx @@ -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 { - 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 PanelContainer; + dashboard: DashboardModel; } export class DashboardGrid extends React.Component { 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 { 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 { this.panelMap[newPos.i].updateGridPos(newPos); } - this.dashboard.sortPanelsByGridPos(); + this.props.dashboard.sortPanelsByGridPos(); } triggerForceUpdate() { @@ -137,11 +134,15 @@ export class DashboardGrid extends React.Component { } 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 { 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 */ -
- +
+
); } @@ -192,8 +190,8 @@ export class DashboardGrid extends React.Component { PanelContainer; + dashboard: DashboardModel; } -export class DashboardPanel extends React.Component { +export interface State { + pluginExports: PluginExports; +} + +export class DashboardPanel extends React.Component { 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 ; + } + + renderAddPanel() { + return ; + } + + 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 = ''; + 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 ( +
+
+ +
+ {this.props.panel.isEditing && ( +
+ +
+ )} +
+ ); } render() { - // special handling for rows - if (this.props.panel.type === 'row') { - return ; + if (this.isSpecial()) { + return this.specialPanels[this.props.panel.type](); } - if (this.props.panel.type === 'add-panel') { - return ; + if (!this.state.pluginExports) { + return null; } - return ( -
this.element = element} className="panel-height-helper" /> - ); + if (this.state.pluginExports.PanelComponent) { + return this.renderReactPanel(); + } + + // legacy angular rendering + return
(this.element = element)} className="panel-height-helper" />; } } - diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx index 378cf4c2c7c..5b8ced9b2b1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx @@ -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 { - dashboard: any; - panelContainer: any; - constructor(props) { super(props); @@ -21,9 +18,6 @@ export class DashboardRow extends React.Component { 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 { } 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 { } update() { - this.dashboard.processRepeats(); + this.props.dashboard.processRepeats(); this.forceUpdate(); } @@ -61,14 +55,10 @@ export class DashboardRow extends React.Component { 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 { 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 (
diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx new file mode 100644 index 00000000000..d0122363668 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx @@ -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 { + 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 ( +
+

Loading

+
+ ); + } + + return ( + <> + {this.loadingSpinner} + {this.props.children({ + timeSeries, + loading, + })} + + ); + } + + private get loadingSpinner(): JSX.Element { + const { loading } = this.state; + + if (loading === LoadingState.Loading) { + return ( +
+ +
+ ); + } + + return null; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx new file mode 100644 index 00000000000..82b366d8126 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -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; +} + +export interface State { + refreshCounter: number; + timeRange?: TimeRange; +} + +export class PanelChrome extends PureComponent { + 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 ( +
+ +
+ + {({ loading, timeSeries }) => { + return ; + }} + +
+
+ ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelContainer.ts b/public/app/features/dashboard/dashgrid/PanelContainer.ts deleted file mode 100644 index 87f3235a176..00000000000 --- a/public/app/features/dashboard/dashgrid/PanelContainer.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DashboardModel } from '../dashboard_model'; -import { PanelLoader } from './PanelLoader'; - -export interface PanelContainer { - getPanelLoader(): PanelLoader; - getDashboard(): DashboardModel; -} diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx new file mode 100644 index 00000000000..26ac8b7d2c1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx @@ -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 { + 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 ; + } + + renderPanelOptions() { + const { pluginExports } = this.props; + + if (pluginExports.PanelOptions) { + const PanelOptions = pluginExports.PanelOptions; + return ; + } else { + return

Visualization has no options

; + } + } + + renderVizTab() { + return ( +
+
+ +
+
+
Options
+ {this.renderPanelOptions()} +
+
+ ); + } + + onChangeTab = (tab: PanelEditorTab) => { + store.dispatch( + updateLocation({ + query: { tab: tab.id }, + partial: true, + }) + ); + }; + + render() { + const { location } = store.getState(); + const activeTab = location.query.tab || 'queries'; + + return ( +
+
+
    + {this.tabs.map(tab => { + return ; + })} +
+ + +
+ +
+ {activeTab === 'queries' && this.renderQueriesTab()} + {activeTab === 'visualization' && this.renderVizTab()} +
+
+ ); + } +} + +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 ( +
  • + onClick(tab)}> + {tab.text} + +
  • + ); +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader.tsx new file mode 100644 index 00000000000..12d5cd37253 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader.tsx @@ -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 { + 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 ( +
    + + + + + + {isLoading && ( + + + + )} + +
    + + + {this.props.panel.title} + + + + + + 4m + + +
    +
    + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx new file mode 100644 index 00000000000..f13f212826a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -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 { + element: any; + component: AngularComponent; + + constructor(props) { + super(props); + } + + componentDidMount() { + if (!this.element) { + return; + } + + const { panel, dashboard } = this.props; + + const loader = getAngularLoader(); + const template = ''; + 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
    (this.element = element)} className="panel-height-helper" />; + } +} diff --git a/public/app/features/dashboard/dashgrid/VizTypePicker.tsx b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx new file mode 100644 index 00000000000..9402133df34 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx @@ -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 { + 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 ( +
    this.props.onTypeChanged(plugin)} title={plugin.name}> + +
    {plugin.name}
    +
    + ); + }; + + render() { + return ( +
    +
    +
    + +
    +
    +
    + +
    {this.state.pluginList.map(this.renderVizPlugin)}
    +
    +
    +
    + ); + } +} diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index c4095e7948b..7312d6db784 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -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); } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index 9a1e7fb9200..ebf8a6bb224 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -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(); } diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts index 048a51efead..b6a70ee4b98 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/settings/settings.ts @@ -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 }); }); diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/share_snapshot_ctrl.ts index ec487801948..ac09d63054d 100644 --- a/public/app/features/dashboard/share_snapshot_ctrl.ts +++ b/public/app/features/dashboard/share_snapshot_ctrl.ts @@ -46,8 +46,7 @@ export class ShareSnapshotCtrl { $scope.loading = true; $scope.snapshot.external = external; - - $rootScope.$broadcast('refresh'); + $scope.dashboard.startRefresh(); $timeout(() => { $scope.saveSnapshot(external); diff --git a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx index 872d9296d12..c5f66fed32a 100644 --- a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx +++ b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx @@ -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(); + wrapper = shallow(); }); it('should fetch all panels sorted with core plugins first', () => { diff --git a/public/app/features/dashboard/specs/DashboardRow.test.tsx b/public/app/features/dashboard/specs/DashboardRow.test.tsx index 3d89c22f962..77c6cb39d9d 100644 --- a/public/app/features/dashboard/specs/DashboardRow.test.tsx +++ b/public/app/features/dashboard/specs/DashboardRow.test.tsx @@ -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(); + wrapper = shallow(); }); 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(); + wrapper = shallow(); 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(); + wrapper = shallow(); expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0); }); }); diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/specs/exporter.test.ts index c7a232f925b..f21e151f3dd 100644 --- a/public/app/features/dashboard/specs/exporter.test.ts +++ b/public/app/features/dashboard/specs/exporter.test.ts @@ -240,5 +240,5 @@ stubs['-- Grafana --'] = { }; function getStub(arg) { - return Promise.resolve(stubs[arg]); + return Promise.resolve(stubs[arg || 'gfdb']); } diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/specs/viewstate_srv.test.ts index 905ffb8b355..f9963afbf85 100644 --- a/public/app/features/dashboard/specs/viewstate_srv.test.ts +++ b/public/app/features/dashboard/specs/viewstate_srv.test.ts @@ -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 }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 9e923f6bcb7..749edef58fb 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -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 { + 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 { + return async dispatch => { + await getBackendSrv().delete(`/api/dashboards/${uri}`); + dispatch(loadPluginDashboards()); + }; +} diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/submenu/submenu.ts index e1288b2b2ed..184d29facee 100644 --- a/public/app/features/dashboard/submenu/submenu.ts +++ b/public/app/features/dashboard/submenu/submenu.ts @@ -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) { diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts index a96bc89daa7..03b4a408125 100644 --- a/public/app/features/dashboard/time_srv.ts +++ b/public/app/features/dashboard/time_srv.ts @@ -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); diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/timepicker/settings.html index 3cb8ca061fb..fd5170013c2 100644 --- a/public/app/features/dashboard/timepicker/settings.html +++ b/public/app/features/dashboard/timepicker/settings.html @@ -5,7 +5,7 @@
    - +
    diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/timepicker/timepicker.ts index c133203cefc..c89e49b54b3 100644 --- a/public/app/features/dashboard/timepicker/timepicker.ts +++ b/public/app/features/dashboard/timepicker/timepicker.ts @@ -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); diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/view_state_srv.ts index d9ad6827567..8805050831e 100644 --- a/public/app/features/dashboard/view_state_srv.ts +++ b/public/app/features/dashboard/view_state_srv.ts @@ -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); }, }; } diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/DashboardsTable.test.tsx new file mode 100644 index 00000000000..10a14f464e4 --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.test.tsx @@ -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(); +}; + +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(); + }); +}); diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx new file mode 100644 index 00000000000..b732782c23b --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.tsx @@ -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 = ({ dashboards, onImport, onRemove }) => { + function buttonText(dashboard: PluginDashboard) { + return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import'; + } + + return ( + + + {dashboards.map((dashboard, index) => { + return ( + + + + + + ); + })} + +
    + + + {dashboard.imported ? ( + {dashboard.title} + ) : ( + {dashboard.title} + )} + + {!dashboard.imported ? ( + + ) : ( + + )} + {dashboard.imported && ( + + )} +
    + ); +}; + +export default DashboardsTable; diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx new file mode 100644 index 00000000000..7409572b9cd --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.test.tsx @@ -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(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx new file mode 100644 index 00000000000..51f9c9ca945 --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -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 { + 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 ( +
    + +
    + this.onImport(dashboard, overwrite)} + onRemove={dashboard => this.onRemove(dashboard)} + /> +
    +
    + ); + } +} + +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)); diff --git a/public/app/features/datasources/DataSourceSettings.tsx b/public/app/features/datasources/DataSourceSettings.tsx new file mode 100644 index 00000000000..f7d641d34b0 --- /dev/null +++ b/public/app/features/datasources/DataSourceSettings.tsx @@ -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 { + 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 ( +
    +

    Settings

    +
    +
    +
    +
    + Name + +
    +
    +
    + {this.shouldRenderInfoBox() &&
    {this.getInfoText()}
    } + {this.isReadyOnly() && ( +
    + This datasource was added by config and cannot be modified using the UI. Please contact your server admin + to update this datasource. +
    + )} +
    + + + + Back + +
    +
    +
    + ); + } +} + +function mapStateToProps(state) { + return { + dataSource: state.dataSources.dataSource, + dataSourceMeta: state.dataSources.dataSourceMeta, + }; +} + +export default connect(mapStateToProps)(DataSourceSettings); diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index 527ecf6db83..c6eaa893d97 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -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, }; diff --git a/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap new file mode 100644 index 00000000000..a85cbd4ad9b --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + +
    +`; + +exports[`Render should render table 1`] = ` + + + + + + + + + + + + + +
    + + + + Graphite Carbon Metrics + + + +
    + + + + Graphite Carbon Metrics + + + + +
    +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap new file mode 100644 index 00000000000..7a4f05a227f --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + +
    + +
    +
    +`; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 33d6b79c5df..bb8fce8424a 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -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 = ThunkAction; @@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult { }; } +export function loadDataSource(id: number): ThunkResult { + 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 { return async (dispatch, getStore) => { await dispatch(loadDataSources()); diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts new file mode 100644 index 00000000000..e0b6b39588e --- /dev/null +++ b/public/app/features/datasources/state/navModel.ts @@ -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 + ); +} diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 9b84799dcea..7e235f5ea0a 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -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; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 80e1400114f..eef176eb49a 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -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; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 5a496ea910b..4fe67d9d37b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -644,7 +644,9 @@ export class Explore extends React.PureComponent { /> )} {supportsTable && showingTable ? ( - +
    +
    + ) : null} {supportsLogs && showingLogs ? : null} diff --git a/public/app/features/explore/PromQueryField.test.tsx b/public/app/features/explore/PromQueryField.test.tsx index c82a1cd448f..802bb695f2c 100644 --- a/public/app/features/explore/PromQueryField.test.tsx +++ b/public/app/features/explore/PromQueryField.test.tsx @@ -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( - + ).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( + + ).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( diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx index 889666c5e35..442e51af987 100644 --- a/public/app/features/explore/PromQueryField.tsx +++ b/public/app/features/explore/PromQueryField.tsx @@ -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 -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.PureComponentLog labels ) : ( - - - - )} + + + + )}
    diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index c89893b4f28..c3c41b7ab17 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -228,7 +228,13 @@ class QueryField extends React.PureComponent 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 { + getCellProps = (state, rowInfo, column) => { + return { + onClick: () => { + const columnKey = column.Header; + const rowValue = rowInfo.row[columnKey]; + this.props.onClickCell(columnKey, rowValue); + }, }; - return ( -
    - ); - } - return ; -} + }; -export default class Table extends PureComponent { 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 ( -
    - - {value} - - {value}
    - - - - - - - - - - -
    Table
    The queries returned no data for a table.
    - ); - } + const columnNames = tableModel.columns.map(({ text }) => text); + const columns = tableModel.columns.map(({ filterable, text }) => ({ + Header: text, + accessor: text, + show: text !== 'Time', + Cell: row => {row.value}, + })); + const noDataText = data ? 'The queries returned no data for a table.' : ''; + return ( - - - {tableModel.columns.map(col => )} - - - {tableModel.rows.map((row, i) => ( - - {row.map((value, j) => ( - - ))} - - ))} - -
    {col.text}
    + prepareRows(data, columnNames)} + showPagination={data} + /> ); } } diff --git a/public/app/features/explore/utils/prometheus.ts b/public/app/features/explore/utils/prometheus.ts index 8c41b94d684..170c5ec8cc5 100644 --- a/public/app/features/explore/utils/prometheus.ts +++ b/public/app/features/explore/utils/prometheus.ts @@ -28,7 +28,7 @@ export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; const selectorRegexp = /\{[^}]*?\}/; -const labelRegexp = /\b\w+="[^"\n]*?"/g; +const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } { if (!query.match(selectorRegexp)) { // Special matcher for metrics @@ -66,11 +66,8 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any // Extract clean labels to form clean selector, incomplete labels are dropped const selector = query.slice(prefixOpen, suffixClose); const labels = {}; - selector.replace(labelRegexp, match => { - const delimiterIndex = match.indexOf('='); - const key = match.slice(0, delimiterIndex); - const value = match.slice(delimiterIndex + 1, match.length); - labels[key] = value; + selector.replace(labelRegexp, (_, key, operator, value) => { + labels[key] = { value, operator }; return ''; }); @@ -78,12 +75,12 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any const metricPrefix = query.slice(0, prefixOpen); const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/); if (metricMatch) { - labels['__name__'] = `"${metricMatch[0]}"`; + labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' }; } // Build sorted selector const labelKeys = Object.keys(labels).sort(); - const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(','); + const cleanSelector = labelKeys.map(key => `${key}${labels[key].operator}${labels[key].value}`).join(','); const selectorString = ['{', cleanSelector, '}'].join(''); diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index b42b06f1238..e517c48bb59 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -7,13 +7,11 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import * as rangeUtil from 'app/core/utils/rangeutil'; import * as dateMath from 'app/core/utils/datemath'; import { getExploreUrl } from 'app/core/utils/explore'; - import { metricsTabDirective } from './metrics_tab'; class MetricsPanelCtrl extends PanelCtrl { scope: any; datasource: any; - datasourceName: any; $q: any; $timeout: any; contextSrv: any; @@ -45,10 +43,6 @@ class MetricsPanelCtrl extends PanelCtrl { this.scope = $scope; this.panel.datasource = this.panel.datasource || null; - if (!this.panel.targets) { - this.panel.targets = [{}]; - } - this.events.on('refresh', this.onMetricsPanelRefresh.bind(this)); this.events.on('init-edit-mode', this.onInitMetricsPanelEditMode.bind(this)); this.events.on('panel-teardown', this.onPanelTearDown.bind(this)); @@ -62,7 +56,7 @@ class MetricsPanelCtrl extends PanelCtrl { } private onInitMetricsPanelEditMode() { - this.addEditorTab('Metrics', metricsTabDirective); + this.addEditorTab('Metrics', metricsTabDirective, 1, 'fa fa-database'); this.addEditorTab('Time range', 'public/app/features/panel/partials/panelTime.html'); } @@ -291,27 +285,6 @@ class MetricsPanelCtrl extends PanelCtrl { }); } - setDatasource(datasource) { - // switching to mixed - if (datasource.meta.mixed) { - _.each(this.panel.targets, target => { - target.datasource = this.panel.datasource; - if (!target.datasource) { - target.datasource = config.defaultDatasource; - } - }); - } else if (this.datasource && this.datasource.meta.mixed) { - _.each(this.panel.targets, target => { - delete target.datasource; - }); - } - - this.panel.datasource = datasource.value; - this.datasourceName = datasource.name; - this.datasource = null; - this.refresh(); - } - getAdditionalMenuItems() { const items = []; if ( diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts index 3a1d0abe1c2..f520b5eefc0 100644 --- a/public/app/features/panel/metrics_tab.ts +++ b/public/app/features/panel/metrics_tab.ts @@ -1,6 +1,14 @@ -import { DashboardModel } from '../dashboard/dashboard_model'; +// Libraries +import _ from 'lodash'; import Remarkable from 'remarkable'; +// Services & utils +import coreModule from 'app/core/core_module'; +import config from 'app/core/config'; + +// Types +import { DashboardModel } from '../dashboard/dashboard_model'; + export class MetricsTabCtrl { dsName: string; panel: any; @@ -24,6 +32,9 @@ export class MetricsTabCtrl { $scope.ctrl = this; this.panel = this.panelCtrl.panel; + this.panel.datasource = this.panel.datasource || null; + this.panel.targets = this.panel.targets || [{}]; + this.dashboard = this.panelCtrl.dashboard; this.datasources = datasourceSrv.getMetricSources(); this.panelDsValue = this.panelCtrl.panel.datasource; @@ -66,10 +77,29 @@ export class MetricsTabCtrl { } this.datasourceInstance = option.datasource; - this.panelCtrl.setDatasource(option.datasource); + this.setDatasource(option.datasource); this.updateDatasourceOptions(); } + setDatasource(datasource) { + // switching to mixed + if (datasource.meta.mixed) { + _.each(this.panel.targets, target => { + target.datasource = this.panel.datasource; + if (!target.datasource) { + target.datasource = config.defaultDatasource; + } + }); + } else if (this.datasourceInstance && this.datasourceInstance.meta.mixed) { + _.each(this.panel.targets, target => { + delete target.datasource; + }); + } + + this.panel.datasource = datasource.value; + this.panel.refresh(); + } + addMixedQuery(option) { if (!option) { return; @@ -120,3 +150,5 @@ export function metricsTabDirective() { controller: MetricsTabCtrl, }; } + +coreModule.directive('metricsTab', metricsTabDirective); diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index e2ae5cc78a9..5e216f6b34d 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -24,10 +24,8 @@ export class PanelCtrl { $injector: any; $location: any; $timeout: any; - fullscreen: boolean; inspector: any; editModeInitiated: boolean; - editMode: any; height: any; containerHeight: any; events: Emitter; @@ -49,7 +47,6 @@ export class PanelCtrl { this.pluginName = plugin.name; } - $scope.$on('refresh', () => this.refresh()); $scope.$on('component-did-mount', () => this.panelDidMount()); $scope.$on('$destroy', () => { @@ -58,13 +55,9 @@ export class PanelCtrl { }); } - init() { - this.events.emit('panel-initialized'); - this.publishAppEvent('panel-initialized', { scope: this.$scope }); - } - panelDidMount() { this.events.emit('component-did-mount'); + this.dashboard.panelInitialized(this.panel); } renderingCompleted() { @@ -72,7 +65,7 @@ export class PanelCtrl { } refresh() { - this.events.emit('refresh', null); + this.panel.refresh(); } publishAppEvent(evtName, evt) { @@ -102,6 +95,7 @@ export class PanelCtrl { initEditMode() { this.editorTabs = []; this.addEditorTab('General', 'public/app/partials/panelgeneral.html'); + this.editModeInitiated = true; this.events.emit('init-edit-mode', null); @@ -122,14 +116,15 @@ export class PanelCtrl { route.updateParams(); } - addEditorTab(title, directiveFn, index?) { - const editorTab = { title, directiveFn }; + addEditorTab(title, directiveFn, index?, icon?) { + const editorTab = { title, directiveFn, icon }; if (_.isString(directiveFn)) { editorTab.directiveFn = () => { return { templateUrl: directiveFn }; }; } + if (index) { this.editorTabs.splice(index, 0, editorTab); } else { @@ -190,7 +185,7 @@ export class PanelCtrl { getExtendedMenu() { const menu = []; - if (!this.fullscreen && this.dashboard.meta.canEdit) { + if (!this.panel.fullscreen && this.dashboard.meta.canEdit) { menu.push({ text: 'Duplicate', click: 'ctrl.duplicate()', @@ -220,15 +215,15 @@ export class PanelCtrl { } otherPanelInFullscreenMode() { - return this.dashboard.meta.fullscreen && !this.fullscreen; + return this.dashboard.meta.fullscreen && !this.panel.fullscreen; } calculatePanelHeight() { - if (this.fullscreen) { - const docHeight = $(window).height(); - const editHeight = Math.floor(docHeight * 0.4); + if (this.panel.fullscreen) { + const docHeight = $('.react-grid-layout').height(); + const editHeight = Math.floor(docHeight * 0.35); const fullscreenHeight = Math.floor(docHeight * 0.8); - this.containerHeight = this.editMode ? editHeight : fullscreenHeight; + this.containerHeight = this.panel.isEditing ? editHeight : fullscreenHeight; } else { this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + (this.panel.gridPos.h - 1) * GRID_CELL_VMARGIN; } @@ -237,6 +232,11 @@ export class PanelCtrl { this.containerHeight = $(window).height(); } + // hacky solution + if (this.panel.isEditing && !this.editModeInitiated) { + this.initEditMode(); + } + this.height = this.containerHeight - (PANEL_BORDER + TITLE_HEIGHT); } @@ -247,9 +247,6 @@ export class PanelCtrl { duplicate() { this.dashboard.duplicatePanel(this.panel); - this.$timeout(() => { - this.$scope.$root.$broadcast('render'); - }); } removePanel() { diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 8b742e17952..77ebf754b3a 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -6,48 +6,53 @@ import baron from 'baron'; const module = angular.module('grafana.directives'); const panelTemplate = ` -
    -
    - - - - +
    +
    +
    +
    + + + + - - - + + + - -
    + +
    -
    - -
    -
    - -
    -
    -
    -

    - {{ctrl.pluginName}} -

    - - - - +
    + +
    +
    -
    -
    - +
    +
    +
    +

    + {{ctrl.pluginName}} +

    + + + + +
    + +
    +
    + +
    @@ -85,10 +90,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { ctrl.dashboard.setPanelFocus(0); } - function panelHeightUpdated() { - panelContent.css({ height: ctrl.height + 'px' }); - } - function resizeScrollableContent() { if (panelScrollbar) { panelScrollbar.update(); @@ -133,7 +134,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { ctrl.events.on('panel-size-changed', () => { ctrl.calculatePanelHeight(); - panelHeightUpdated(); $timeout(() => { resizeScrollableContent(); ctrl.render(); @@ -142,7 +142,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { // set initial height ctrl.calculatePanelHeight(); - panelHeightUpdated(); ctrl.events.on('render', () => { if (transparentLastState !== ctrl.panel.transparent) { diff --git a/public/app/features/panel/panel_editor_tab.ts b/public/app/features/panel/panel_editor_tab.ts index 13a9369856a..f7e1c48a323 100644 --- a/public/app/features/panel/panel_editor_tab.ts +++ b/public/app/features/panel/panel_editor_tab.ts @@ -1,6 +1,7 @@ import angular from 'angular'; const directiveModule = angular.module('grafana.directives'); +const directiveCache = {}; /** @ngInject */ function panelEditorTab(dynamicDirectiveSrv) { @@ -12,17 +13,24 @@ function panelEditorTab(dynamicDirectiveSrv) { }, directive: scope => { const pluginId = scope.ctrl.pluginId; - const tabIndex = scope.index; - // create a wrapper for directiveFn - // required for metrics tab directive - // that is the same for many panels but - // given different names in this function - const fn = () => scope.editorTab.directiveFn(); + const tabName = scope.editorTab.title.toLowerCase().replace(' ', '-'); - return Promise.resolve({ - name: `panel-editor-tab-${pluginId}${tabIndex}`, - fn: fn, - }); + if (directiveCache[pluginId]) { + if (directiveCache[pluginId][tabName]) { + return directiveCache[pluginId][tabName]; + } + } else { + directiveCache[pluginId] = []; + } + + const result = { + fn: () => scope.editorTab.directiveFn(), + name: `panel-editor-tab-${pluginId}${tabName}`, + }; + + directiveCache[pluginId][tabName] = result; + + return result; }, }); } diff --git a/public/app/features/panel/panel_header.ts b/public/app/features/panel/panel_header.ts index 5fa20c4714b..1d29d04ad98 100644 --- a/public/app/features/panel/panel_header.ts +++ b/public/app/features/panel/panel_header.ts @@ -8,21 +8,6 @@ const template = ` {{ctrl.timeInfo}} diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html index 0ee1f81b0c3..815a99d6b74 100644 --- a/public/app/features/panel/partials/metrics_tab.html +++ b/public/app/features/panel/partials/metrics_tab.html @@ -1,11 +1,7 @@
    - - { + this.dashboard.changePanelType(this.panelCtrl.panel, plugin.id); + }; +} + +const template = ` +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    `; + +/** @ngInject */ +export function vizTabDirective() { + 'use strict'; + return { + restrict: 'E', + template: template, + controller: VizTabCtrl, + }; +} + +react2AngularDirective('vizTypePicker', VizTypePicker, ['currentType', ['onTypeChanged', { watchDepth: 'reference' }]]); +coreModule.directive('vizTab', vizTabDirective); diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index d164a6d4255..c9fb250266c 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -2,6 +2,5 @@ import './plugin_edit_ctrl'; import './plugin_page_ctrl'; import './import_list/import_list'; import './ds_edit_ctrl'; -import './ds_dashboards_ctrl'; import './datasource_srv'; import './plugin_component'; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index e29e1709ccf..b9779190a8b 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -14,6 +14,8 @@ import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module'; import * as stackdriverPlugin from 'app/plugins/datasource/stackdriver/module'; import * as textPanel from 'app/plugins/panel/text/module'; +import * as text2Panel from 'app/plugins/panel/text2/module'; +import * as graph2Panel from 'app/plugins/panel/graph2/module'; import * as graphPanel from 'app/plugins/panel/graph/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; @@ -40,6 +42,8 @@ const builtInPlugins = { 'app/plugins/datasource/stackdriver/module': stackdriverPlugin, 'app/plugins/panel/text/module': textPanel, + 'app/plugins/panel/text2/module': text2Panel, + 'app/plugins/panel/graph2/module': graph2Panel, 'app/plugins/panel/graph/module': graphPanel, 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 7ef82519668..71a417a882f 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -1,8 +1,14 @@ +// Libraries import _ from 'lodash'; import coreModule from 'app/core/core_module'; + +// Utils import config from 'app/core/config'; import { importPluginModule } from './plugin_loader'; +// Types +import { DataSourceApi } from 'app/types/series'; + export class DatasourceSrv { datasources: any; @@ -15,7 +21,7 @@ export class DatasourceSrv { this.datasources = {}; } - get(name?) { + get(name?): Promise { if (!name) { return this.get(config.defaultDatasource); } @@ -162,5 +168,15 @@ export class DatasourceSrv { } } +let singleton: DatasourceSrv; + +export function setDatasourceSrv(srv: DatasourceSrv) { + singleton = srv; +} + +export function getDatasourceSrv(): DatasourceSrv { + return singleton; +} + coreModule.service('datasourceSrv', DatasourceSrv); export default DatasourceSrv; diff --git a/public/app/features/plugins/partials/ds_dashboards.html b/public/app/features/plugins/partials/ds_dashboards.html deleted file mode 100644 index a442e37d1f6..00000000000 --- a/public/app/features/plugins/partials/ds_dashboards.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
    - - - -
    diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts index 41d1b6f1deb..142eb942a30 100644 --- a/public/app/features/plugins/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -8,7 +8,7 @@ import { importPluginModule } from './plugin_loader'; import { UnknownPanelCtrl } from 'app/plugins/panel/unknown/module'; /** @ngInject */ -function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $templateCache) { +function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $templateCache, $timeout) { function getTemplate(component) { if (component.template) { return $q.when(component.template); @@ -95,7 +95,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ PanelCtrl.templatePromise = getTemplate(PanelCtrl).then(template => { PanelCtrl.templateUrl = null; - PanelCtrl.template = `${template}`; + PanelCtrl.template = `${template}`; return componentInfo; }); @@ -207,10 +207,13 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // let a binding digest cycle complete before adding to dom setTimeout(() => { - elem.append(child); scope.$applyAsync(() => { - scope.$broadcast('component-did-mount'); - scope.$broadcast('refresh'); + elem.append(child); + setTimeout(() => { + scope.$applyAsync(() => { + scope.$broadcast('component-did-mount'); + }); + }); }); }); } @@ -245,7 +248,6 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ registerPluginComponent(scope, elem, attrs, componentInfo); }) .catch(err => { - $rootScope.appEvent('alert-error', ['Plugin Error', err.message || err]); console.log('Plugin component error', err); }); }, diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index bc3c719917c..8e0958f6c1b 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -18,6 +18,7 @@ import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import TableModel from 'app/core/table_model'; import { coreModule, appEvents, contextSrv } from 'app/core/core'; +import { PluginExports } from 'app/types/plugins'; import * as datemath from 'app/core/utils/datemath'; import * as fileExport from 'app/core/utils/file_export'; import * as flatten from 'app/core/utils/flatten'; @@ -140,11 +141,12 @@ const flotDeps = [ 'jquery.flot.events', 'jquery.flot.gauge', ]; + for (const flotDep of flotDeps) { exposeToPlugin(flotDep, { fakeDep: 1 }); } -export function importPluginModule(path: string): Promise { +export function importPluginModule(path: string): Promise { const builtIn = builtInPlugins[path]; if (builtIn) { return Promise.resolve(builtIn); diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts index dcfd510ffa0..9f53f3c1fd0 100644 --- a/public/app/features/plugins/state/actions.ts +++ b/public/app/features/plugins/state/actions.ts @@ -2,9 +2,11 @@ import { Plugin, StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from '../../../core/services/backend_srv'; import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; +import { PluginDashboard } from '../../../types/plugins'; export enum ActionTypes { LoadPlugins = 'LOAD_PLUGINS', + LoadPluginDashboards = 'LOAD_PLUGIN_DASHBOARDS', SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY', SetLayoutMode = 'SET_LAYOUT_MODE', } @@ -14,6 +16,11 @@ export interface LoadPluginsAction { payload: Plugin[]; } +export interface LoadPluginDashboardsAction { + type: ActionTypes.LoadPluginDashboards; + payload: PluginDashboard[]; +} + export interface SetPluginsSearchQueryAction { type: ActionTypes.SetPluginsSearchQuery; payload: string; @@ -39,7 +46,12 @@ const pluginsLoaded = (plugins: Plugin[]): LoadPluginsAction => ({ payload: plugins, }); -export type Action = LoadPluginsAction | SetPluginsSearchQueryAction | SetLayoutModeAction; +const pluginDashboardsLoaded = (dashboards: PluginDashboard[]): LoadPluginDashboardsAction => ({ + type: ActionTypes.LoadPluginDashboards, + payload: dashboards, +}); + +export type Action = LoadPluginsAction | LoadPluginDashboardsAction | SetPluginsSearchQueryAction | SetLayoutModeAction; type ThunkResult = ThunkAction; @@ -49,3 +61,12 @@ export function loadPlugins(): ThunkResult { dispatch(pluginsLoaded(result)); }; } + +export function loadPluginDashboards(): ThunkResult { + return async (dispatch, getStore) => { + const dataSourceType = getStore().dataSources.dataSource.type; + + const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`); + dispatch(pluginDashboardsLoaded(response)); + }; +} diff --git a/public/app/features/plugins/state/navModel.ts b/public/app/features/plugins/state/navModel.ts index 852eb2806f9..f12967ebb7a 100644 --- a/public/app/features/plugins/state/navModel.ts +++ b/public/app/features/plugins/state/navModel.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { DataSource, PluginMeta, NavModel } from 'app/types'; +import config from 'app/core/config'; export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: string): NavModel { let title = 'New'; @@ -38,6 +39,16 @@ export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: s }); } + if (config.buildInfo.isEnterprise) { + main.children.push({ + active: currentPage === 'datasource-permissions', + icon: 'fa fa-fw fa-lock', + id: 'datasource-permissions', + text: 'Permissions', + url: `datasources/edit/${ds.id}/permissions`, + }); + } + return { main: main, node: _.find(main.children, { active: true }), diff --git a/public/app/features/plugins/state/reducers.ts b/public/app/features/plugins/state/reducers.ts index bd99d2029f5..0d464ef1772 100644 --- a/public/app/features/plugins/state/reducers.ts +++ b/public/app/features/plugins/state/reducers.ts @@ -1,12 +1,14 @@ import { Action, ActionTypes } from './actions'; import { Plugin, PluginsState } from 'app/types'; import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; +import { PluginDashboard } from '../../../types/plugins'; export const initialState: PluginsState = { plugins: [] as Plugin[], searchQuery: '', layoutMode: LayoutModes.Grid, hasFetched: false, + dashboards: [] as PluginDashboard[], }; export const pluginsReducer = (state = initialState, action: Action): PluginsState => { @@ -19,6 +21,9 @@ export const pluginsReducer = (state = initialState, action: Action): PluginsSta case ActionTypes.SetLayoutMode: return { ...state, layoutMode: action.payload }; + + case ActionTypes.LoadPluginDashboards: + return { ...state, dashboards: action.payload }; } return state; }; diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts index 359d5b79a38..3df6ccb8b5b 100644 --- a/public/app/features/templating/specs/variable_srv.test.ts +++ b/public/app/features/templating/specs/variable_srv.test.ts @@ -1,5 +1,6 @@ import '../all'; import { VariableSrv } from '../variable_srv'; +import { DashboardModel } from '../../dashboard/dashboard_model'; import moment from 'moment'; import $q from 'q'; @@ -56,10 +57,12 @@ describe('VariableSrv', function(this: any) { return getVarMockConstructor(ctr, model, ctx); }; - ctx.variableSrv.init({ - templating: { list: [] }, - updateSubmenuVisibility: () => {}, - }); + ctx.variableSrv.init( + new DashboardModel({ + templating: { list: [] }, + updateSubmenuVisibility: () => {}, + }) + ); scenario.variable = ctx.variableSrv.createVariableFromModel(scenario.variableModel); ctx.variableSrv.addVariable(scenario.variable); diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts index b5d00a5289e..bda5b6aa577 100644 --- a/public/app/features/templating/specs/variable_srv_init.test.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -2,6 +2,7 @@ import '../all'; import _ from 'lodash'; import { VariableSrv } from '../variable_srv'; +import { DashboardModel } from '../../dashboard/dashboard_model'; import $q from 'q'; describe('VariableSrv init', function(this: any) { @@ -56,9 +57,9 @@ describe('VariableSrv init', function(this: any) { ctx.variableSrv.datasourceSrv = ctx.datasourceSrv; ctx.variableSrv.$location.search = () => scenario.urlParams; - ctx.variableSrv.dashboard = { + ctx.variableSrv.dashboard = new DashboardModel({ templating: { list: scenario.variables }, - }; + }); await ctx.variableSrv.init(ctx.variableSrv.dashboard); diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index 75e2ca35ec7..a676f9c2848 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -1,5 +1,8 @@ +// Libaries import angular from 'angular'; import _ from 'lodash'; + +// Utils & Services import coreModule from 'app/core/core_module'; import { variableTypes } from './variable'; import { Graph } from 'app/core/utils/dag'; @@ -10,13 +13,12 @@ export class VariableSrv { /** @ngInject */ constructor(private $rootScope, private $q, private $location, private $injector, private templateSrv) { - // update time variant variables - $rootScope.$on('refresh', this.onDashboardRefresh.bind(this), $rootScope); $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope); } init(dashboard) { this.dashboard = dashboard; + this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this)); // create working class models representing variables this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this)); @@ -39,11 +41,7 @@ export class VariableSrv { }); } - onDashboardRefresh(evt, payload) { - if (payload && payload.fromVariableValueUpdated) { - return Promise.resolve({}); - } - + onTimeRangeUpdated() { const promises = this.variables.filter(variable => variable.refresh === 2).map(variable => { const previousOptions = variable.options.slice(); @@ -54,7 +52,9 @@ export class VariableSrv { }); }); - return this.$q.all(promises); + return this.$q.all(promises).then(() => { + this.dashboard.startRefresh(); + }); } processVariable(variable, queryParams) { @@ -133,7 +133,7 @@ export class VariableSrv { return this.$q.all(promises).then(() => { if (emitChangeEvents) { this.$rootScope.$emit('template-variable-value-updated'); - this.$rootScope.$broadcast('refresh', { fromVariableValueUpdated: true }); + this.dashboard.startRefresh(); } }); } diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html index 9506587c515..32acdc435f2 100644 --- a/public/app/partials/dashboard.html +++ b/public/app/partials/dashboard.html @@ -7,12 +7,11 @@ class="dashboard-settings"> -
    +
    - - +
    diff --git a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts index ff0d39944ca..6fe48cb1715 100644 --- a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts +++ b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts @@ -1,17 +1,21 @@ +import _ from 'lodash'; export class CloudWatchConfigCtrl { static templateUrl = 'partials/config.html'; current: any; + datasourceSrv: any; accessKeyExist = false; secretKeyExist = false; /** @ngInject */ - constructor($scope) { + constructor($scope, datasourceSrv) { this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp'; this.current.jsonData.authType = this.current.jsonData.authType || 'credentials'; this.accessKeyExist = this.current.secureJsonFields.accessKey; this.secretKeyExist = this.current.secureJsonFields.secretKey; + this.datasourceSrv = datasourceSrv; + this.getRegions(); } resetAccessKey() { @@ -36,4 +40,47 @@ export class CloudWatchConfigCtrl { { name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' }, { name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' }, ]; + + regions = [ + 'ap-northeast-1', + 'ap-northeast-2', + 'ap-northeast-3', + 'ap-south-1', + 'ap-southeast-1', + 'ap-southeast-2', + 'ca-central-1', + 'cn-north-1', + 'cn-northwest-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-gov-east-1', + 'us-gov-west-1', + 'us-iso-east-1', + 'us-isob-east-1', + 'us-west-1', + 'us-west-2', + ]; + + getRegions() { + this.datasourceSrv + .loadDatasource(this.current.name) + .then(ds => { + return ds.getRegions(); + }) + .then( + regions => { + this.regions = _.map(regions, 'value'); + }, + err => { + console.error('failed to get latest regions'); + } + ); + } } diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 7821c5dcb2c..b4f739f934c 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -137,7 +137,11 @@ export default class CloudWatchDatasource { if (res.results) { _.forEach(res.results, queryRes => { _.forEach(queryRes.series, series => { - data.push({ target: series.name, datapoints: series.points, unit: queryRes.meta.unit || 'none' }); + const s = { target: series.name, datapoints: series.points } as any; + if (queryRes.meta.unit) { + s.unit = queryRes.meta.unit; + } + data.push(s); }); }); } diff --git a/public/app/plugins/datasource/cloudwatch/partials/config.html b/public/app/plugins/datasource/cloudwatch/partials/config.html index e5ab0910cba..40249d32b7e 100644 --- a/public/app/plugins/datasource/cloudwatch/partials/config.html +++ b/public/app/plugins/datasource/cloudwatch/partials/config.html @@ -39,7 +39,7 @@
    - + Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region. diff --git a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts index 4f4b2961761..ba5a39688b3 100644 --- a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts +++ b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts @@ -1,4 +1,5 @@ import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; export class CloudWatchQueryParameter { @@ -239,5 +240,5 @@ export class CloudWatchQueryParameterCtrl { } } -angular.module('grafana.controllers').directive('cloudwatchQueryParameter', CloudWatchQueryParameter); -angular.module('grafana.controllers').controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl); +coreModule.directive('cloudwatchQueryParameter', CloudWatchQueryParameter); +coreModule.controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts index 8963f2c3f4b..cacf86201fe 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as queryDef from './query_def'; @@ -226,6 +226,5 @@ export class ElasticBucketAggCtrl { } } -const module = angular.module('grafana.directives'); -module.directive('elasticBucketAgg', elasticBucketAgg); -module.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl); +coreModule.directive('elasticBucketAgg', elasticBucketAgg); +coreModule.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.ts b/public/app/plugins/datasource/elasticsearch/metric_agg.ts index 623eed68914..1dd0d892360 100644 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.ts +++ b/public/app/plugins/datasource/elasticsearch/metric_agg.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as queryDef from './query_def'; @@ -203,6 +203,5 @@ export class ElasticMetricAggCtrl { } } -const module = angular.module('grafana.directives'); -module.directive('elasticMetricAgg', elasticMetricAgg); -module.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl); +coreModule.directive('elasticMetricAgg', elasticMetricAgg); +coreModule.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl); diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts index a5c1dc49959..ea3dfe8ff5e 100644 --- a/public/app/plugins/datasource/graphite/add_graphite_func.ts +++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts @@ -1,8 +1,8 @@ -import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import rst2html from 'rst2html'; import Drop from 'tether-drop'; +import coreModule from 'app/core/core_module'; /** @ngInject */ export function graphiteAddFunc($compile) { @@ -130,7 +130,7 @@ export function graphiteAddFunc($compile) { }; } -angular.module('grafana.directives').directive('graphiteAddFunc', graphiteAddFunc); +coreModule.directive('graphiteAddFunc', graphiteAddFunc); function createFunctionDropDownMenu(funcDefs) { const categories = {}; diff --git a/public/app/plugins/datasource/graphite/func_editor.ts b/public/app/plugins/datasource/graphite/func_editor.ts index 68cc6f1452e..9e19083a9c3 100644 --- a/public/app/plugins/datasource/graphite/func_editor.ts +++ b/public/app/plugins/datasource/graphite/func_editor.ts @@ -1,7 +1,7 @@ -import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import rst2html from 'rst2html'; +import coreModule from 'app/core/core_module'; /** @ngInject */ export function graphiteFuncEditor($compile, templateSrv, popoverSrv) { @@ -315,4 +315,4 @@ export function graphiteFuncEditor($compile, templateSrv, popoverSrv) { }; } -angular.module('grafana.directives').directive('graphiteFuncEditor', graphiteFuncEditor); +coreModule.directive('graphiteFuncEditor', graphiteFuncEditor); diff --git a/public/app/plugins/datasource/mssql/config_ctrl.ts b/public/app/plugins/datasource/mssql/config_ctrl.ts new file mode 100644 index 00000000000..c80d657a914 --- /dev/null +++ b/public/app/plugins/datasource/mssql/config_ctrl.ts @@ -0,0 +1,10 @@ +export class MssqlConfigCtrl { + static templateUrl = 'partials/config.html'; + + current: any; + + /** @ngInject */ + constructor($scope) { + this.current.jsonData.encrypt = this.current.jsonData.encrypt || 'false'; + } +} diff --git a/public/app/plugins/datasource/mssql/module.ts b/public/app/plugins/datasource/mssql/module.ts index 478ecadcb3e..bf46b6d0947 100644 --- a/public/app/plugins/datasource/mssql/module.ts +++ b/public/app/plugins/datasource/mssql/module.ts @@ -1,9 +1,6 @@ import { MssqlDatasource } from './datasource'; import { MssqlQueryCtrl } from './query_ctrl'; - -class MssqlConfigCtrl { - static templateUrl = 'partials/config.html'; -} +import { MssqlConfigCtrl } from './config_ctrl'; const defaultQuery = `SELECT as time, diff --git a/public/app/plugins/datasource/mssql/partials/config.html b/public/app/plugins/datasource/mssql/partials/config.html index 4cb42e90f7c..db76f60e5e3 100644 --- a/public/app/plugins/datasource/mssql/partials/config.html +++ b/public/app/plugins/datasource/mssql/partials/config.html @@ -27,6 +27,22 @@ reset
    + +
    + +
    + + + Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server. +
      +
    • disable - Data sent between client and server is not encrypted.
    • +
    • false - Data sent between client and server is not encrypted beyond the login packet. (default)
    • +
    • true - Data sent between client and server is encrypted.
    • +
    + 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. +
    +
    +
    Connection limits diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts index f1db05cabe8..13948c5d793 100644 --- a/public/app/plugins/datasource/postgres/datasource.ts +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -20,7 +20,7 @@ export class PostgresDatasource { this.interval = (instanceSettings.jsonData || {}).timeInterval; } - interpolateVariable(value, variable) { + interpolateVariable = (value, variable) => { if (typeof value === 'string') { if (variable.multi || variable.includeAll) { return this.queryModel.quoteLiteral(value); @@ -37,7 +37,7 @@ export class PostgresDatasource { return this.queryModel.quoteLiteral(v); }); return quotedValues.join(','); - } + }; query(options) { const queries = _.filter(options.targets, target => { diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 7ea748e1082..b77abdbdab3 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -89,7 +89,7 @@ export default class StackdriverDatasource { } resolvePanelUnitFromTargets(targets: any[]) { - let unit = 'none'; + let unit; if (targets.length > 0 && targets.every(t => t.unit === targets[0].unit)) { if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit)) { unit = stackdriverUnitMappings[targets[0].unit]; @@ -109,13 +109,16 @@ export default class StackdriverDatasource { const unit = this.resolvePanelUnitFromTargets(options.targets); queryRes.series.forEach(series => { - result.push({ + let timeSerie: any = { target: series.name, datapoints: series.points, refId: queryRes.refId, meta: queryRes.meta, - unit, - }); + }; + if (unit) { + timeSerie = { ...timeSerie, unit }; + } + result.push(timeSerie); }); }); } diff --git a/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts index 98a1258cb15..6cd6c805463 100644 --- a/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as options from './constants'; import kbn from 'app/core/utils/kbn'; @@ -83,5 +83,5 @@ export class StackdriverAggregationCtrl { } } -angular.module('grafana.controllers').directive('stackdriverAggregation', StackdriverAggregation); -angular.module('grafana.controllers').controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl); +coreModule.directive('stackdriverAggregation', StackdriverAggregation); +coreModule.controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl); diff --git a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts index 786b2831e89..7af76720d23 100644 --- a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import { FilterSegments, DefaultRemoveFilterValue } from './filter_segments'; import appEvents from 'app/core/app_events'; @@ -281,5 +281,5 @@ export class StackdriverFilterCtrl { } } -angular.module('grafana.controllers').directive('stackdriverFilter', StackdriverFilter); -angular.module('grafana.controllers').controller('StackdriverFilterCtrl', StackdriverFilterCtrl); +coreModule.directive('stackdriverFilter', StackdriverFilter); +coreModule.controller('StackdriverFilterCtrl', StackdriverFilterCtrl); diff --git a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts index 3117be402a9..ab0c0653816 100644 --- a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts +++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts @@ -235,8 +235,8 @@ describe('StackdriverDataSource', () => { beforeEach(() => { res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }]); }); - it('should return none', () => { - expect(res).toEqual('none'); + it('should return undefined', () => { + expect(res).toBeUndefined(); }); }); describe('and the stackdriver unit has a corresponding grafana unit', () => { @@ -262,16 +262,16 @@ describe('StackdriverDataSource', () => { beforeEach(() => { res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }, { unit: 'megaseconds' }]); }); - it('should return the default value - none', () => { - expect(res).toEqual('none'); + it('should return the default value of undefined', () => { + expect(res).toBeUndefined(); }); }); describe('and all target units are not the same', () => { beforeEach(() => { res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }]); }); - it('should return the default value - none', () => { - expect(res).toEqual('none'); + it('should return the default value of undefined', () => { + expect(res).toBeUndefined(); }); }); }); diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index d112e656f3f..0197626cd0b 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -62,7 +62,6 @@ class TestDataDatasource { }); } - console.log(res); return { data: data }; }); } diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index e83c537809c..c16cab52ccf 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -133,9 +133,9 @@ class GraphCtrl extends MetricsPanelCtrl { } onInitEditMode() { + this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4); this.addEditorTab('Axes', axesEditorComponent, 2); this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html', 3); - this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4); if (config.alertingEnabled) { this.addEditorTab('Alert', alertTab, 5); diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.ts b/public/app/plugins/panel/graph/series_overrides_ctrl.ts index 33520cb403b..934fd835347 100644 --- a/public/app/plugins/panel/graph/series_overrides_ctrl.ts +++ b/public/app/plugins/panel/graph/series_overrides_ctrl.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import angular from 'angular'; +import coreModule from 'app/core/core_module'; /** @ngInject */ export function SeriesOverridesCtrl($scope, $element, popoverSrv) { @@ -156,4 +156,4 @@ export function SeriesOverridesCtrl($scope, $element, popoverSrv) { $scope.updateCurrentOverrides(); } -angular.module('grafana.controllers').controller('SeriesOverridesCtrl', SeriesOverridesCtrl); +coreModule.controller('SeriesOverridesCtrl', SeriesOverridesCtrl); diff --git a/public/app/plugins/panel/graph2/README.md b/public/app/plugins/panel/graph2/README.md new file mode 100644 index 00000000000..667ab51784a --- /dev/null +++ b/public/app/plugins/panel/graph2/README.md @@ -0,0 +1,5 @@ +# Text Panel - Native Plugin + +The Text Panel is **included** with Grafana. + +The Text Panel is a very simple panel that displays text. The source text is written in the Markdown syntax meaning you can format the text. Read [GitHub's Mastering Markdown](https://guides.github.com/features/mastering-markdown/) to learn more. diff --git a/public/app/plugins/panel/graph2/img/icn-text-panel.svg b/public/app/plugins/panel/graph2/img/icn-text-panel.svg new file mode 100644 index 00000000000..a9d0a1d2c4a --- /dev/null +++ b/public/app/plugins/panel/graph2/img/icn-text-panel.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx new file mode 100644 index 00000000000..c2b8c355440 --- /dev/null +++ b/public/app/plugins/panel/graph2/module.tsx @@ -0,0 +1,43 @@ +// Libraries +import _ from 'lodash'; +import React, { PureComponent } from 'react'; + +// Components +import Graph from 'app/viz/Graph'; +import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; + +// Types +import { PanelProps, NullValueMode } from 'app/types'; + +interface Options { + showBars: boolean; +} + +interface Props extends PanelProps { + options: Options; +} + +export class Graph2 extends PureComponent { + constructor(props) { + super(props); + } + + render() { + const { timeSeries, timeRange } = this.props; + + const vmSeries = getTimeSeriesVMs({ + timeSeries: timeSeries, + nullValueMode: NullValueMode.Ignore, + }); + + return ; + } +} + +export class TextOptions extends PureComponent { + render() { + return

    Text2 Options component

    ; + } +} + +export { Graph2 as PanelComponent, TextOptions as PanelOptions }; diff --git a/public/app/plugins/panel/graph2/plugin.json b/public/app/plugins/panel/graph2/plugin.json new file mode 100644 index 00000000000..b519a57fae4 --- /dev/null +++ b/public/app/plugins/panel/graph2/plugin.json @@ -0,0 +1,17 @@ +{ + "type": "panel", + "name": "React Graph", + "id": "graph2", + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-text-panel.svg", + "large": "img/icn-text-panel.svg" + } + } +} + diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts index 628186569dd..0e011e59439 100644 --- a/public/app/plugins/panel/heatmap/color_legend.ts +++ b/public/app/plugins/panel/heatmap/color_legend.ts @@ -1,12 +1,10 @@ -import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import * as d3 from 'd3'; import { contextSrv } from 'app/core/core'; import { tickStep } from 'app/core/utils/ticks'; import { getColorScale, getOpacityScale } from './color_scale'; - -const module = angular.module('grafana.directives'); +import coreModule from 'app/core/core_module'; const LEGEND_HEIGHT_PX = 6; const LEGEND_WIDTH_PX = 100; @@ -16,7 +14,7 @@ const LEGEND_VALUE_MARGIN = 0; /** * Color legend for heatmap editor. */ -module.directive('colorLegend', () => { +coreModule.directive('colorLegend', () => { return { restrict: 'E', template: '
    ', @@ -52,7 +50,7 @@ module.directive('colorLegend', () => { /** * Heatmap legend with scale values. */ -module.directive('heatmapLegend', () => { +coreModule.directive('heatmapLegend', () => { return { restrict: 'E', template: `
    `, diff --git a/public/app/plugins/panel/text2/README.md b/public/app/plugins/panel/text2/README.md new file mode 100644 index 00000000000..667ab51784a --- /dev/null +++ b/public/app/plugins/panel/text2/README.md @@ -0,0 +1,5 @@ +# Text Panel - Native Plugin + +The Text Panel is **included** with Grafana. + +The Text Panel is a very simple panel that displays text. The source text is written in the Markdown syntax meaning you can format the text. Read [GitHub's Mastering Markdown](https://guides.github.com/features/mastering-markdown/) to learn more. diff --git a/public/app/plugins/panel/text2/img/icn-graph-panel.svg b/public/app/plugins/panel/text2/img/icn-graph-panel.svg new file mode 100644 index 00000000000..463b3d5770b --- /dev/null +++ b/public/app/plugins/panel/text2/img/icn-graph-panel.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/text2/module.tsx b/public/app/plugins/panel/text2/module.tsx new file mode 100644 index 00000000000..b10dc8b545e --- /dev/null +++ b/public/app/plugins/panel/text2/module.tsx @@ -0,0 +1,14 @@ +import React, { PureComponent } from 'react'; +import { PanelProps } from 'app/types'; + +export class Text2 extends PureComponent { + constructor(props) { + super(props); + } + + render() { + return

    Text Panel!

    ; + } +} + +export { Text2 as PanelComponent }; diff --git a/public/app/plugins/panel/text2/plugin.json b/public/app/plugins/panel/text2/plugin.json new file mode 100644 index 00000000000..b1133f65e36 --- /dev/null +++ b/public/app/plugins/panel/text2/plugin.json @@ -0,0 +1,19 @@ +{ + "type": "panel", + "name": "Text v2", + "id": "text2", + + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-graph-panel.svg", + "large": "img/icn-graph-panel.svg" + } + } +} + diff --git a/public/app/core/components/grafana_app.ts b/public/app/routes/GrafanaCtrl.ts similarity index 96% rename from public/app/core/components/grafana_app.ts rename to public/app/routes/GrafanaCtrl.ts index 2774ab99426..d6291c94a6f 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -8,9 +8,10 @@ import appEvents from 'app/core/app_events'; import Drop from 'tether-drop'; import colors from 'app/core/utils/colors'; import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { configureStore } from 'app/store/configureStore'; +import { TimeSrv, setTimeSrv } from 'app/features/dashboard/time_srv'; +import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader'; +import { configureStore } from 'app/store/configureStore'; export class GrafanaCtrl { /** @ngInject */ @@ -23,12 +24,15 @@ export class GrafanaCtrl { contextSrv, bridgeSrv, backendSrv: BackendSrv, + timeSrv: TimeSrv, datasourceSrv: DatasourceSrv, angularLoader: AngularLoader ) { - // sets singleston instances for angular services so react components can access them + // make angular loader service available to react components setAngularLoader(angularLoader); setBackendSrv(backendSrv); + setDatasourceSrv(datasourceSrv); + setTimeSrv(timeSrv); configureStore(); $scope.init = () => { diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index e94275f6aef..94b6b2706f2 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -13,6 +13,7 @@ import FolderPermissions from 'app/features/folders/FolderPermissions'; import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage'; import NewDataSourcePage from '../features/datasources/NewDataSourcePage'; import UsersListPage from 'app/features/users/UsersListPage'; +import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards'; /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { @@ -78,9 +79,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { controllerAs: 'ctrl', }) .when('/datasources/edit/:id/dashboards', { - templateUrl: 'public/app/features/plugins/partials/ds_dashboards.html', - controller: 'DataSourceDashboardsCtrl', - controllerAs: 'ctrl', + template: '', + resolve: { + component: () => DataSourceDashboards, + }, }) .when('/datasources/new', { template: '', diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 0a93a4baa0f..ccd027a0b6d 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -11,7 +11,7 @@ import pluginReducers from 'app/features/plugins/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; -const rootReducer = combineReducers({ +const rootReducers = { ...sharedReducers, ...alertingReducers, ...teamsReducers, @@ -21,13 +21,19 @@ const rootReducer = combineReducers({ ...pluginReducers, ...dataSourcesReducers, ...usersReducers, -}); +}; export let store; +export function addRootReducer(reducers) { + Object.assign(rootReducers, ...reducers); +} + export function configureStore() { const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + const rootReducer = combineReducers(rootReducers); + if (process.env.NODE_ENV !== 'production') { // DEV builds we had the logger middleware store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger()))); diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index fa5ace388c4..21f7bdac2d4 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -61,6 +61,11 @@ export enum PermissionLevel { Admin = 4, } +export enum DataSourcePermissionLevel { + Query = 1, + Admin = 2, +} + export enum AclTarget { Team = 'Team', User = 'User', @@ -73,6 +78,10 @@ export interface AclTargetInfo { text: string; } +export const dataSourceAclLevels = [ + { value: DataSourcePermissionLevel.Query, label: 'Query', description: 'Can query data source.' }, +]; + export const dashboardAclTargets: AclTargetInfo[] = [ { value: AclTarget.Team, text: 'Team' }, { value: AclTarget.User, text: 'User' }, diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 0614cd2da62..8e1991dcd9f 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -12,10 +12,10 @@ export interface DataSource { password: string; user: string; database: string; - basicAuth: false; - isDefault: false; + basicAuth: boolean; + isDefault: boolean; jsonData: { authType: string; defaultRegion: string }; - readOnly: false; + readOnly: boolean; } export interface DataSourcesState { @@ -25,5 +25,7 @@ export interface DataSourcesState { layoutMode: LayoutMode; dataSourcesCount: number; dataSourceTypes: Plugin[]; + dataSource: DataSource; + dataSourceMeta: Plugin; hasFetched: boolean; } diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 26f15d582ac..27c1644e6ab 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -8,7 +8,20 @@ import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys'; import { Invitee, OrgUser, User, UsersState } from './user'; import { DataSource, DataSourcesState } from './datasources'; -import { PluginMeta, Plugin, PluginsState } from './plugins'; +import { + TimeRange, + LoadingState, + TimeSeries, + TimeSeriesVM, + TimeSeriesVMs, + TimeSeriesStats, + NullValueMode, + DataQuery, + DataQueryResponse, + DataQueryOptions, +} from './series'; +import { PanelProps } from './panel'; +import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins'; export { Team, @@ -45,6 +58,18 @@ export { OrgUser, User, UsersState, + TimeRange, + LoadingState, + PanelProps, + TimeSeries, + TimeSeriesVM, + TimeSeriesVMs, + NullValueMode, + TimeSeriesStats, + DataQuery, + DataQueryResponse, + DataQueryOptions, + PluginDashboard, }; export interface StoreState { diff --git a/public/app/types/location.ts b/public/app/types/location.ts index 4a7f51523a7..7dcf57f7e02 100644 --- a/public/app/types/location.ts +++ b/public/app/types/location.ts @@ -2,6 +2,7 @@ export interface LocationUpdate { path?: string; query?: UrlQueryMap; routeParams?: UrlQueryMap; + partial?: boolean; } export interface LocationState { diff --git a/public/app/types/panel.ts b/public/app/types/panel.ts new file mode 100644 index 00000000000..5ece77fc5aa --- /dev/null +++ b/public/app/types/panel.ts @@ -0,0 +1,7 @@ +import { LoadingState, TimeSeries, TimeRange } from './series'; + +export interface PanelProps { + timeSeries: TimeSeries[]; + timeRange: TimeRange; + loading: LoadingState; +} diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index c7e9aa7a564..826ce2d48ec 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -1,3 +1,25 @@ +export interface PluginExports { + PanelCtrl?; + PanelComponent?: any; + Datasource?: any; + QueryCtrl?: any; + ConfigCtrl?: any; + AnnotationsQueryCtrl?: any; + PanelOptions?: any; +} + +export interface PanelPlugin { + id: string; + name: string; + meta: any; + hideFromList: boolean; + module: string; + baseUrl: string; + info: any; + sort: number; + exports?: PluginExports; +} + export interface PluginMeta { id: string; name: string; @@ -40,9 +62,26 @@ export interface Plugin { type: string; } +export interface PluginDashboard { + dashboardId: number; + description: string; + folderId: number; + imported: boolean; + importedRevision: number; + importedUri: string; + importedUrl: string; + path: string; + pluginId: string; + removed: boolean; + revision: number; + slug: string; + title: string; +} + export interface PluginsState { plugins: Plugin[]; searchQuery: string; layoutMode: string; hasFetched: boolean; + dashboards: PluginDashboard[]; } diff --git a/public/app/types/series.ts b/public/app/types/series.ts new file mode 100644 index 00000000000..5396880611b --- /dev/null +++ b/public/app/types/series.ts @@ -0,0 +1,91 @@ +import { Moment } from 'moment'; + +export enum LoadingState { + NotStarted = 'NotStarted', + Loading = 'Loading', + Done = 'Done', + Error = 'Error', +} + +export interface RawTimeRange { + from: Moment | string; + to: Moment | string; +} + +export interface TimeRange { + from: Moment; + to: Moment; + raw: RawTimeRange; +} + +export type TimeSeriesValue = string | number | null; + +export type TimeSeriesPoints = TimeSeriesValue[][]; + +export interface TimeSeries { + target: string; + datapoints: TimeSeriesPoints; + unit?: string; +} + +/** View model projection of a time series */ +export interface TimeSeriesVM { + label: string; + color: string; + data: TimeSeriesValue[][]; + stats: TimeSeriesStats; +} + +export interface TimeSeriesStats { + total: number; + max: number; + min: number; + logmin: number; + avg: number | null; + current: number | null; + first: number | null; + delta: number; + diff: number | null; + range: number | null; + timeStep: number; + count: number; + allIsNull: boolean; + allIsZero: boolean; +} + +export enum NullValueMode { + Null = 'null', + Ignore = 'connected', + AsZero = 'null as zero', +} + +/** View model projection of many time series */ +export interface TimeSeriesVMs { + [index: number]: TimeSeriesVM; +} + +export interface DataQueryResponse { + data: TimeSeries[]; +} + +export interface DataQuery { + refId: string; +} + +export interface DataQueryOptions { + timezone: string; + range: TimeRange; + rangeRaw: RawTimeRange; + targets: DataQuery[]; + panelId: number; + dashboardId: number; + cacheTimeout?: string; + interval: string; + intervalMs: number; + maxDataPoints: number; + scopedVars: object; +} + +export interface DataSourceApi { + query(options: DataQueryOptions): Promise; +} diff --git a/public/app/viz/Graph.tsx b/public/app/viz/Graph.tsx new file mode 100644 index 00000000000..fab65225715 --- /dev/null +++ b/public/app/viz/Graph.tsx @@ -0,0 +1,124 @@ +// Libraries +import $ from 'jquery'; +import React, { PureComponent } from 'react'; +import { withSize } from 'react-sizeme'; +import 'vendor/flot/jquery.flot'; +import 'vendor/flot/jquery.flot.time'; + +// Types +import { TimeRange, TimeSeriesVMs } from 'app/types'; + +// Copied from graph.ts +function time_format(ticks, min, max) { + if (min && max && ticks) { + const range = max - min; + const secPerTick = range / ticks / 1000; + const oneDay = 86400000; + const oneYear = 31536000000; + + if (secPerTick <= 45) { + return '%H:%M:%S'; + } + if (secPerTick <= 7200 || range <= oneDay) { + return '%H:%M'; + } + if (secPerTick <= 80000) { + return '%m/%d %H:%M'; + } + if (secPerTick <= 2419200 || range <= oneYear) { + return '%m/%d'; + } + return '%Y-%m'; + } + + return '%H:%M'; +} + +const FLOT_OPTIONS = { + legend: { + show: false, + }, + series: { + lines: { + linewidth: 1, + zero: false, + }, + shadowSize: 0, + }, + grid: { + minBorderMargin: 0, + markings: [], + backgroundColor: null, + borderWidth: 0, + // hoverable: true, + clickable: true, + color: '#a1a1a1', + margin: { left: 0, right: 0 }, + labelMarginX: 0, + }, +}; + +interface GraphProps { + timeSeries: TimeSeriesVMs; + timeRange: TimeRange; + size?: { width: number; height: number }; +} + +export class Graph extends PureComponent { + element: any; + + componentDidUpdate(prevProps: GraphProps) { + if ( + prevProps.timeSeries !== this.props.timeSeries || + prevProps.timeRange !== this.props.timeRange || + prevProps.size !== this.props.size + ) { + this.draw(); + } + } + + componentDidMount() { + this.draw(); + } + + draw() { + const { size, timeSeries, timeRange } = this.props; + + if (!size) { + return; + } + + const ticks = (size.width || 0) / 100; + const min = timeRange.from.valueOf(); + const max = timeRange.to.valueOf(); + + const dynamicOptions = { + xaxis: { + mode: 'time', + min: min, + max: max, + label: 'Datetime', + ticks: ticks, + timeformat: time_format(ticks, min, max), + }, + }; + + const options = { + ...FLOT_OPTIONS, + ...dynamicOptions, + }; + + console.log('plot', timeSeries, options); + $.plot(this.element, timeSeries, options); + } + + render() { + return ( +
    +
    (this.element = e)} /> +
    + ); + } +} + +export default withSize()(Graph); diff --git a/public/app/viz/state/timeSeries.ts b/public/app/viz/state/timeSeries.ts new file mode 100644 index 00000000000..e22cb4681b7 --- /dev/null +++ b/public/app/viz/state/timeSeries.ts @@ -0,0 +1,168 @@ +// Libraries +import _ from 'lodash'; + +// Utils +import colors from 'app/core/utils/colors'; + +// Types +import { TimeSeries, TimeSeriesVMs, NullValueMode } from 'app/types'; + +interface Options { + timeSeries: TimeSeries[]; + nullValueMode: NullValueMode; +} + +export function getTimeSeriesVMs({ timeSeries, nullValueMode }: Options): TimeSeriesVMs { + const vmSeries = timeSeries.map((item, index) => { + const colorIndex = index % colors.length; + const label = item.target; + const result = []; + + // stat defaults + let total = 0; + let max = -Number.MAX_VALUE; + let min = Number.MAX_VALUE; + let logmin = Number.MAX_VALUE; + let avg = null; + let current = null; + let first = null; + let delta = 0; + let diff = null; + let range = null; + let timeStep = Number.MAX_VALUE; + let allIsNull = true; + let allIsZero = true; + + const ignoreNulls = nullValueMode === NullValueMode.Ignore; + const nullAsZero = nullValueMode === NullValueMode.AsZero; + + let currentTime; + let currentValue; + let nonNulls = 0; + let previousTime; + let previousValue = 0; + let previousDeltaUp = true; + + for (let i = 0; i < item.datapoints.length; i++) { + currentValue = item.datapoints[i][0]; + currentTime = item.datapoints[i][1]; + + // Due to missing values we could have different timeStep all along the series + // so we have to find the minimum one (could occur with aggregators such as ZimSum) + if (previousTime !== undefined) { + const currentStep = currentTime - previousTime; + if (currentStep < timeStep) { + timeStep = currentStep; + } + } + + previousTime = currentTime; + + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + + if (currentValue !== null) { + if (_.isNumber(currentValue)) { + total += currentValue; + allIsNull = false; + nonNulls++; + } + + if (currentValue > max) { + max = currentValue; + } + + if (currentValue < min) { + min = currentValue; + } + + if (first === null) { + first = currentValue; + } else { + if (previousValue > currentValue) { + // counter reset + previousDeltaUp = false; + if (i === item.datapoints.length - 1) { + // reset on last + delta += currentValue; + } + } else { + if (previousDeltaUp) { + delta += currentValue - previousValue; // normal increment + } else { + delta += currentValue; // account for counter reset + } + previousDeltaUp = true; + } + } + previousValue = currentValue; + + if (currentValue < logmin && currentValue > 0) { + logmin = currentValue; + } + + if (currentValue !== 0) { + allIsZero = false; + } + } + + result.push([currentTime, currentValue]); + } + + if (max === -Number.MAX_VALUE) { + max = null; + } + + if (min === Number.MAX_VALUE) { + min = null; + } + + if (result.length && !allIsNull) { + avg = total / nonNulls; + current = result[result.length - 1][1]; + if (current === null && result.length > 1) { + current = result[result.length - 2][1]; + } + } + + if (max !== null && min !== null) { + range = max - min; + } + + if (current !== null && first !== null) { + diff = current - first; + } + + const count = result.length; + + return { + data: result, + label: label, + color: colors[colorIndex], + stats: { + total, + min, + max, + current, + logmin, + avg, + diff, + delta, + timeStep, + range, + count, + first, + allIsZero, + allIsNull, + }, + }; + }); + + return vmSeries; +} diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index f583e481490..e4c7a9c59e1 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -1,4 +1,7 @@ -// vendor +// DEPENDENCIES +@import '../../node_modules/react-table/react-table.css'; + +// VENDOR @import '../vendor/css/timepicker.css'; @import '../vendor/css/spectrum.css'; @import '../vendor/css/rc-cascader.scss'; @@ -94,6 +97,7 @@ @import 'components/form_select_box'; @import 'components/user-picker'; @import 'components/description-picker'; +@import 'components/viz_editor'; @import 'components/delete_button'; @import 'components/add_data_source.scss'; @import 'components/page_loader'; diff --git a/public/sass/components/_dashboard_grid.scss b/public/sass/components/_dashboard_grid.scss index f1908ca8786..da1f140d252 100644 --- a/public/sass/components/_dashboard_grid.scss +++ b/public/sass/components/_dashboard_grid.scss @@ -20,7 +20,6 @@ } // Disable grid interaction indicators in fullscreen panels - .panel-header:hover { background-color: inherit; } @@ -32,6 +31,11 @@ .react-resizable-handle { display: none; } + + // the react-grid has a height transition + .react-grid-layout { + transition-property: none; + } } @include media-breakpoint-down(sm) { diff --git a/public/sass/components/_panel_add_panel.scss b/public/sass/components/_panel_add_panel.scss index 5bfff31a108..263b181262e 100644 --- a/public/sass/components/_panel_add_panel.scss +++ b/public/sass/components/_panel_add_panel.scss @@ -85,10 +85,6 @@ height: calc(100% - 15px); } -.add-panel__item-icon { - padding: 2px; -} - .add-panel__searchbar { width: 100%; margin-bottom: 10px; diff --git a/public/sass/components/_scrollbar.scss b/public/sass/components/_scrollbar.scss index adb9e0c54c0..00bd5f7c94c 100644 --- a/public/sass/components/_scrollbar.scss +++ b/public/sass/components/_scrollbar.scss @@ -307,6 +307,7 @@ .view { display: flex; flex-grow: 1; + flex-direction: column; } .track-vertical { @@ -337,3 +338,7 @@ border-radius: 6px; } } + +.scroll-margin-helper { + margin-right: 12px; +} diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss index a8291aa1a87..b70990d4618 100644 --- a/public/sass/components/_slate_editor.scss +++ b/public/sass/components/_slate_editor.scss @@ -2,6 +2,7 @@ font-size: $font-size-root; font-family: $font-family-monospace; height: auto; + word-break: break-word; } .slate-query-field-wrapper { diff --git a/public/sass/components/_tabbed_view.scss b/public/sass/components/_tabbed_view.scss index bf95d453504..87b43a31142 100644 --- a/public/sass/components/_tabbed_view.scss +++ b/public/sass/components/_tabbed_view.scss @@ -1,19 +1,15 @@ .tabbed-view { - padding: $spacer*3; - margin-bottom: $dashboard-padding; + display: flex; + flex-direction: column; + height: 100%; - &.tabbed-view--panel-edit { - padding: 0; - - .tabbed-view-header { - padding: 0px 25px; - background: none; - } + &.tabbed-view--new { + padding: 25px 0 0 0; + height: 100%; } } .tabbed-view-header { - background: $page-header-bg; box-shadow: $page-header-shadow; border-bottom: 1px solid $page-header-border-color; @include clearfix(); @@ -48,7 +44,10 @@ } .tabbed-view-body { - padding: $spacer*2 $spacer; + padding: $spacer*2 $spacer $spacer $spacer; + display: flex; + flex-direction: column; + flex: 1; &--small { min-height: 0px; diff --git a/public/sass/components/_viz_editor.scss b/public/sass/components/_viz_editor.scss new file mode 100644 index 00000000000..048e513cfbb --- /dev/null +++ b/public/sass/components/_viz_editor.scss @@ -0,0 +1,81 @@ +.viz-editor { + display: flex; + height: 100%; +} + +.viz-editor-col1 { + width: 210px; + height: 100%; + margin-right: 40px; +} + +.viz-editor-col2 { + flex-grow: 1; +} + +.viz-picker { + display: flex; + flex-direction: column; + height: 100%; +} + +.viz-picker__search { + flex-grow: 0; +} + +.viz-picker__items { + flex-grow: 1; + height: calc(100% - 50px); +} + +.viz-picker__item { + background: $card-background; + box-shadow: $card-shadow; + + border-radius: 3px; + padding: $spacer; + width: 100%; + height: 60px; + text-align: center; + margin-bottom: 6px; + cursor: pointer; + display: flex; + flex-shrink: 0; + border: 1px solid transparent; + @include left-brand-border; + + &:hover { + background: $card-background-hover; + } + + &--selected { + // border: 1px solid $orange; + @include left-brand-border-gradient(); + + .viz-picker__item-name { + color: $text-color; + } + + .viz-picker__item-img { + filter: saturate(100%); + } + } +} + +.viz-picker__item-name { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: $font-size-h5; + display: flex; + flex-direction: column; + align-self: center; + padding-left: $spacer; + font-size: $font-size-md; + color: $text-muted; +} + +.viz-picker__item-img { + height: 100%; + filter: saturate(30%); +} diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 6225f840973..d9ab29cc91c 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -1,7 +1,12 @@ .dashboard-container { padding: $dashboard-padding $dashboard-padding 0 $dashboard-padding; width: 100%; - min-height: 100%; + height: 100%; + box-sizing: border-box; + + &--has-submenu { + height: calc(100% - 50px); + } } .template-variable { @@ -29,16 +34,43 @@ div.flot-text { height: 100%; } +.panel-editor-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.panel-editor-container__panel { + height: 35%; +} + +.panel-editor-container__editor { + height: 65%; +} + .panel-container { background-color: $panel-bg; border: $panel-border; position: relative; border-radius: 3px; + height: 100%; &.panel-transparent { background-color: transparent; border: none; } + + &:hover { + .panel-menu-toggle { + visibility: visible; + transition: opacity 0.1s ease-in 0.2s; + opacity: 1; + } + } + + &--is-editing { + height: auto; + } } .panel-content { @@ -199,14 +231,6 @@ div.flot-text { } } -.panel-hover-highlight { - .panel-menu-toggle { - visibility: visible; - transition: opacity 0.1s ease-in 0.2s; - opacity: 1; - } -} - .panel-time-info { font-weight: bold; float: right; @@ -233,5 +257,5 @@ div.flot-text { } .panel-full-edit { - margin: $dashboard-padding (-$dashboard-padding) 0 (-$dashboard-padding); + padding-top: $dashboard-padding; } diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index f8bd1108fb1..c1198ce06be 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -126,7 +126,7 @@ } .query-row-tools { - width: 6rem; + white-space: nowrap; } .query-row-field { @@ -186,3 +186,60 @@ margin: 0.25em 0.5em 0.5em; } } + +// ReactTable basic overrides (does not include pivot/groups/filters) +// When integrating ReactTable as new panel plugin, move to _panel_table.scss + +.ReactTable { + border: none; + // Allow some space for the no-data text + min-height: 120px; +} + +.ReactTable .rt-thead.-header { + box-shadow: none; + background: $list-item-bg; + border-top: 2px solid $body-bg; + border-bottom: 2px solid $body-bg; + height: 2em; +} +.ReactTable .rt-thead.-header .rt-th { + text-align: left; + color: $blue; + font-weight: 500; +} +.ReactTable .rt-thead .rt-td, +.ReactTable .rt-thead .rt-th { + padding: 0.45em 0 0.45em 1.1em; + border-right: none; + box-shadow: none; +} +.ReactTable .rt-tbody .rt-td { + padding: 0.45em 0 0.45em 1.1em; + border-bottom: 2px solid $body-bg; + border-right: 2px solid $body-bg; +} +.ReactTable .rt-tbody .rt-td:last-child { + border-right: none; +} +.ReactTable .-pagination .-btn { + color: $blue; + background: $list-item-bg; +} +.ReactTable .-pagination input, +.ReactTable .-pagination select { + color: $input-color; + background-color: $input-bg; +} +.ReactTable .-loading { + background: $input-bg; +} +.ReactTable .-loading.-active { + opacity: 0.8; +} +.ReactTable .-loading > div { + color: $input-color; +} +.ReactTable .rt-tr .rt-td:last-child { + text-align: right; +} diff --git a/scripts/build/build-all.sh b/scripts/build/build-all.sh index 0aaab2ce4a6..64e51ca6259 100755 --- a/scripts/build/build-all.sh +++ b/scripts/build/build-all.sh @@ -4,6 +4,10 @@ # This script is executed from within the container. # +set -e + +EXTRA_OPTS="$@" + CCARMV7=arm-linux-gnueabihf-gcc CCARM64=aarch64-linux-gnu-gcc CCOSX64=/tmp/osxcross/target/bin/o64-clang @@ -18,15 +22,20 @@ echo "current dir: $(pwd)" if [ "$CIRCLE_TAG" != "" ]; then echo "Building releases from tag $CIRCLE_TAG" - OPT="-includeBuildNumber=false" + OPT="-includeBuildNumber=false ${EXTRA_OPTS}" else echo "Building incremental build for $CIRCLE_BRANCH" - OPT="-buildNumber=${CIRCLE_BUILD_NUM}" + OPT="-buildNumber=${CIRCLE_BUILD_NUM} ${EXTRA_OPTS}" fi +echo "Build arguments: $OPT" + go run build.go -goarch armv7 -cc ${CCARMV7} ${OPT} build go run build.go -goarch arm64 -cc ${CCARM64} ${OPT} build -go run build.go -goos darwin -cc ${CCOSX64} ${OPT} build + +# MacOS build is broken atm. See Issue #13763 +#go run build.go -goos darwin -cc ${CCOSX64} ${OPT} build + go run build.go -goos windows -cc ${CCWIN64} ${OPT} build CC=${CCX64} go run build.go ${OPT} build diff --git a/scripts/build/build.sh b/scripts/build/build.sh index d4c1c788b30..2cf9f5a8a21 100755 --- a/scripts/build/build.sh +++ b/scripts/build/build.sh @@ -4,6 +4,10 @@ # This script is executed from within the container. # +set -e + +EXTRA_OPTS="$@" + CCX64=/tmp/x86_64-centos6-linux-gnu/bin/x86_64-centos6-linux-gnu-gcc GOPATH=/go @@ -14,12 +18,14 @@ echo "current dir: $(pwd)" if [ "$CIRCLE_TAG" != "" ]; then echo "Building releases from tag $CIRCLE_TAG" - OPT="-includeBuildNumber=false" + OPT="-includeBuildNumber=false ${EXTRA_OPTS}" else echo "Building incremental build for $CIRCLE_BRANCH" - OPT="-buildNumber=${CIRCLE_BUILD_NUM}" + OPT="-buildNumber=${CIRCLE_BUILD_NUM} ${EXTRA_OPTS}" fi +echo "Build arguments: $OPT" + CC=${CCX64} go run build.go ${OPT} build yarn install --pure-lockfile --no-progress diff --git a/scripts/build/build_enterprise.sh b/scripts/build/build_enterprise.sh deleted file mode 100755 index cda3952c36a..00000000000 --- a/scripts/build/build_enterprise.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# -# This script is executed from within the container. -# - -echo "building enterprise version" - -GOPATH=/go -REPO_PATH=$GOPATH/src/github.com/grafana/grafana - - -cd /go/src/github.com/grafana/grafana -echo "current dir: $(pwd)" - -cd .. -git clone -b master --single-branch git@github.com:grafana/grafana-enterprise.git --depth 10 -cd grafana-enterprise -#git checkout 7fbae9c1be3467c4a39cf6ad85278a6896ceb49f -./build.sh - -cd ../grafana - -function exit_if_fail { - command=$@ - echo "Executing '$command'" - eval $command - rc=$? - if [ $rc -ne 0 ]; then - echo "'$command' returned $rc." - exit $rc - fi -} - -exit_if_fail go test ./pkg/extensions/... - - -if [ "$CIRCLE_TAG" != "" ]; then - echo "Building a release from tag $ls" - go run build.go -buildNumber=${CIRCLE_BUILD_NUM} -enterprise=true -includeBuildNumber=false build -else - echo "Building incremental build for $CIRCLE_BRANCH" - go run build.go -buildNumber=${CIRCLE_BUILD_NUM} -enterprise=true build -fi - -yarn install --pure-lockfile --no-progress - -source /etc/profile.d/rvm.sh - -echo "current dir: $(pwd)" - -if [ "$CIRCLE_TAG" != "" ]; then - echo "Packaging a release from tag $CIRCLE_TAG" - go run build.go -buildNumber=${CIRCLE_BUILD_NUM} -enterprise=true -includeBuildNumber=false package latest -else - echo "Packaging incremental build for $CIRCLE_BRANCH" - go run build.go -buildNumber=${CIRCLE_BUILD_NUM} -enterprise=true package latest -fi diff --git a/scripts/build/prepare-enterprise.sh b/scripts/build/prepare-enterprise.sh new file mode 100755 index 00000000000..0e1c3da2dbd --- /dev/null +++ b/scripts/build/prepare-enterprise.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +cd .. +git clone -b master --single-branch git@github.com:grafana/grafana-enterprise.git --depth 1 +cd grafana-enterprise +./build.sh diff --git a/tools/phantomjs/render.js b/tools/phantomjs/render.js index 900660b7ca0..d0fb0234417 100644 --- a/tools/phantomjs/render.js +++ b/tools/phantomjs/render.js @@ -50,19 +50,22 @@ function checkIsReady() { var panelsRendered = page.evaluate(function() { - var panelCount = document.querySelectorAll('.panel').length; + var panelCount = document.querySelectorAll('plugin-component').length; return window.panelsRendered >= panelCount; }); if (panelsRendered || totalWaitMs > timeoutMs) { var bb = page.evaluate(function () { - return document.getElementsByClassName("main-view")[0].getBoundingClientRect(); + var container = document.getElementsByClassName("dashboard-container") + if (container.length == 0) { + container = document.getElementsByClassName("panel-container") + } + return container[0].getBoundingClientRect(); }); - - page.clipRect = { - top: bb.top, - left: bb.left, - width: bb.width, + + // reset viewport to render full page + page.viewportSize = { + width: bb.width, height: bb.height }; diff --git a/yarn.lock b/yarn.lock index 732597f67c6..5c5156b9970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11378,6 +11378,13 @@ react-sizeme@^2.3.6: lodash.throttle "^4.1.1" shallowequal "^1.0.2" +react-table@^6.8.6: + version "6.8.6" + resolved "https://registry.yarnpkg.com/react-table/-/react-table-6.8.6.tgz#a0ad8b4839319052d5befc012603fb161e52ede3" + integrity sha1-oK2LSDkxkFLVvvwBJgP7Fh5S7eM= + dependencies: + classnames "^2.2.5" + react-test-renderer@^16.0.0-0, react-test-renderer@^16.5.0: version "16.5.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae"