Merge branch 'master' into react-panels-step1
This commit is contained in:
@@ -4,6 +4,7 @@ init_cmds = [
|
||||
["./bin/grafana-server", "cfg:app_mode=development"]
|
||||
]
|
||||
watch_all = true
|
||||
follow_symlinks = true
|
||||
watch_dirs = [
|
||||
"$WORKDIR/pkg",
|
||||
"$WORKDIR/public/views",
|
||||
|
||||
@@ -170,6 +170,7 @@ jobs:
|
||||
- scripts/*.sh
|
||||
- scripts/publish
|
||||
- scripts/build/release_publisher/release_publisher
|
||||
- scripts/build/publish.sh
|
||||
|
||||
build:
|
||||
docker:
|
||||
|
||||
+18
-3
@@ -2,22 +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)
|
||||
* **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)
|
||||
* **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)
|
||||
|
||||
* **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)
|
||||
|
||||
# 5.3.1 (2018-10-16)
|
||||
|
||||
* **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616)
|
||||
* **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607)
|
||||
* **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
|
||||
* **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553)
|
||||
* **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628)
|
||||
* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky)
|
||||
* **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650)
|
||||
* **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech)
|
||||
* **HTTP API**: Fix /api/org/users so that query and limit querystrings works
|
||||
|
||||
# 5.3.0 (2018-10-10)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -138,5 +138,5 @@ plugin development.
|
||||
|
||||
## License
|
||||
|
||||
Grafana is distributed under Apache 2.0 License.
|
||||
Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md).
|
||||
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -156,9 +156,9 @@ Since not all datasources have the same configuration settings we only have the
|
||||
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
|
||||
| graphiteVersion | string | Graphite | Graphite version |
|
||||
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
|
||||
| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
|
||||
| timeField | string | Elastic | Which field that should be used as timestamp |
|
||||
| interval | string | Elastic | Index date time format |
|
||||
| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
|
||||
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
|
||||
| interval | string | Elasticsearch | Index date time format |
|
||||
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
|
||||
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
|
||||
| defaultRegion | string | Cloudwatch | AWS region |
|
||||
@@ -166,6 +166,7 @@ Since not all datasources have the same configuration settings we only have the
|
||||
| tsdbVersion | string | OpenTSDB | Version |
|
||||
| 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+) |
|
||||
|
||||
@@ -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".
|
||||
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ docker run \
|
||||
|
||||
## Building a custom Grafana image with pre-installed plugins
|
||||
|
||||
In the [grafana-docker](https://github.com/grafana/grafana-docker/) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
|
||||
In the [grafana-docker](https://github.com/grafana/grafana/tree/master/packaging/docker) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
|
||||
|
||||
Example of how to build and run:
|
||||
```bash
|
||||
@@ -103,6 +103,21 @@ docker run \
|
||||
grafana:latest-with-plugins
|
||||
```
|
||||
|
||||
## Installing Plugins from other sources
|
||||
|
||||
> Only available in Grafana v5.3.1+
|
||||
|
||||
It's possible to install plugins from custom url:s by specifying the url like this: `GF_INSTALL_PLUGINS=<url to plugin zip>;<plugin name>`
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-d \
|
||||
-p 3000:3000 \
|
||||
--name=grafana \
|
||||
-e "GF_INSTALL_PLUGINS=http://plugin-domain.com/my-custom-plugin.zip;custom-plugin" \
|
||||
grafana/grafana
|
||||
```
|
||||
|
||||
## Configuring AWS Credentials for CloudWatch Support
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"stable": "5.3.0",
|
||||
"testing": "5.3.0"
|
||||
"stable": "5.3.1",
|
||||
"testing": "5.3.1"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -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))
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
@@ -251,8 +252,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
|
||||
return Error(403, err.Error(), err)
|
||||
}
|
||||
|
||||
if err == m.ErrDashboardContainsInvalidAlertData {
|
||||
return Error(500, "Invalid alert data. Cannot save dashboard", err)
|
||||
if validationErr, ok := err.(alerting.ValidationError); ok {
|
||||
return Error(422, validationErr.Error(), nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
@@ -725,7 +726,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
{SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412},
|
||||
{SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400},
|
||||
{SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400},
|
||||
{SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500},
|
||||
{SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: 422},
|
||||
{SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500},
|
||||
{SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500},
|
||||
{SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400},
|
||||
|
||||
+18
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+26
-23
@@ -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 {
|
||||
|
||||
@@ -22,7 +22,20 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
|
||||
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{})
|
||||
|
||||
@@ -100,7 +100,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) {
|
||||
sighupChan := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(sighupChan, syscall.SIGHUP)
|
||||
signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
|
||||
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,6 @@ var (
|
||||
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
|
||||
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
|
||||
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
|
||||
ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
|
||||
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
|
||||
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
|
||||
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package conditions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
@@ -31,12 +32,12 @@ type ThresholdEvaluator struct {
|
||||
func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) {
|
||||
params := model.Get("params").MustArray()
|
||||
if len(params) == 0 {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"}
|
||||
return nil, fmt.Errorf("Evaluator missing threshold parameter")
|
||||
}
|
||||
|
||||
firstParam, ok := params[0].(json.Number)
|
||||
if !ok {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"}
|
||||
return nil, fmt.Errorf("Evaluator has invalid parameter")
|
||||
}
|
||||
|
||||
defaultEval := &ThresholdEvaluator{Type: typ}
|
||||
@@ -107,7 +108,7 @@ func (e *RangedEvaluator) Eval(reducedValue null.Float) bool {
|
||||
func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
||||
typ := model.Get("type").MustString()
|
||||
if typ == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator missing type property"}
|
||||
return nil, fmt.Errorf("Evaluator missing type property")
|
||||
}
|
||||
|
||||
if inSlice(typ, defaultTypes) {
|
||||
@@ -122,7 +123,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
||||
return &NoValueEvaluator{}, nil
|
||||
}
|
||||
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator invalid evaluator type: " + typ}
|
||||
return nil, fmt.Errorf("Evaluator invalid evaluator type: %s", typ)
|
||||
}
|
||||
|
||||
func inSlice(a string, list []string) bool {
|
||||
|
||||
@@ -82,8 +82,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
||||
if collapsed && collapsedJSON.MustBool() {
|
||||
|
||||
// extract alerts from sub panels for collapsed panels
|
||||
alertSlice, err := e.getAlertFromPanels(panel,
|
||||
validateAlertFunc)
|
||||
alertSlice, err := e.getAlertFromPanels(panel, validateAlertFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,7 +99,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
||||
|
||||
panelID, err := panel.Get("id").Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("panel id is required. err %v", err)
|
||||
return nil, ValidationError{Reason: "A numeric panel id property is missing"}
|
||||
}
|
||||
|
||||
// backward compatibility check, can be removed later
|
||||
@@ -146,7 +145,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
||||
|
||||
datasource, err := e.lookupDatasourceID(dsName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
e.log.Debug("Error looking up datasource", "error", err)
|
||||
return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)}
|
||||
}
|
||||
|
||||
jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
|
||||
@@ -167,8 +167,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
|
||||
}
|
||||
|
||||
if !validateAlertFunc(alert) {
|
||||
e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
|
||||
return nil, m.ErrDashboardContainsInvalidAlertData
|
||||
return nil, ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelId)}
|
||||
}
|
||||
|
||||
alerts = append(alerts, alert)
|
||||
|
||||
@@ -258,7 +258,7 @@ func TestAlertRuleExtraction(t *testing.T) {
|
||||
|
||||
Convey("Should fail on save", func() {
|
||||
_, err := extractor.GetAlerts()
|
||||
So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData)
|
||||
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ type Notifier interface {
|
||||
GetNotifierId() int64
|
||||
GetIsDefault() bool
|
||||
GetSendReminder() bool
|
||||
GetDisableResolveMessage() bool
|
||||
GetFrequency() time.Duration
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ type ValidationError struct {
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
extraInfo := ""
|
||||
extraInfo := e.Reason
|
||||
if e.Alertid != 0 {
|
||||
extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
|
||||
}
|
||||
|
||||
if e.PanelId != 0 {
|
||||
extraInfo = fmt.Sprintf("%s PanelId: %v ", extraInfo, e.PanelId)
|
||||
extraInfo = fmt.Sprintf("%s PanelId: %v", extraInfo, e.PanelId)
|
||||
}
|
||||
|
||||
if e.DashboardId != 0 {
|
||||
@@ -50,10 +50,10 @@ func (e ValidationError) Error() string {
|
||||
}
|
||||
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s %s%s", e.Err.Error(), e.Reason, extraInfo)
|
||||
return fmt.Sprintf("Alert validation error: %s%s", e.Err.Error(), extraInfo)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Failed to extract alert.Reason: %s %s", e.Reason, extraInfo)
|
||||
return fmt.Sprintf("Alert validation error: %s", extraInfo)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -128,7 +128,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
|
||||
}
|
||||
|
||||
if len(model.Conditions) == 0 {
|
||||
return nil, fmt.Errorf("Alert is missing conditions")
|
||||
return nil, ValidationError{Reason: "Alert is missing conditions"}
|
||||
}
|
||||
|
||||
return model, nil
|
||||
|
||||
@@ -73,7 +73,7 @@ func (srv *CleanUpService) cleanUpTmpFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files))
|
||||
srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "kept", len(files))
|
||||
}
|
||||
|
||||
func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -25,7 +26,9 @@ type DashboardProvisioningService interface {
|
||||
|
||||
// NewService factory for creating a new dashboard service
|
||||
var NewService = func() DashboardService {
|
||||
return &dashboardServiceImpl{}
|
||||
return &dashboardServiceImpl{
|
||||
log: log.New("dashboard-service"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvisioningService factory for creating a new dashboard provisioning service
|
||||
@@ -45,6 +48,7 @@ type SaveDashboardDTO struct {
|
||||
type dashboardServiceImpl struct {
|
||||
orgId int64
|
||||
user *models.SignedInUser
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
|
||||
@@ -89,7 +93,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&validateAlertsCmd); err != nil {
|
||||
return nil, models.ErrDashboardContainsInvalidAlertData
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,12 +117,12 @@ func TestDashboardService(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return errors.New("error")
|
||||
return errors.New("Alert validation error")
|
||||
})
|
||||
|
||||
dto.Dashboard = models.NewDashboard("Dash")
|
||||
_, err := service.SaveDashboard(dto)
|
||||
So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData)
|
||||
So(err.Error(), ShouldEqual, "Alert validation error")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,8 +44,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
s := *query.Result
|
||||
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
s := *query.Result
|
||||
s.Version = 1000
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.NotifierId,
|
||||
Version: s.Version,
|
||||
Id: s.NotifierId,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
@@ -111,8 +111,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
Convey("Updating existing state to pending with incorrect version since alert rule state update version is higher", func() {
|
||||
s := *query.Result
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: 1000,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
@@ -125,8 +125,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
|
||||
s := *query.Result
|
||||
s.Version = 1000
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,10 @@ export interface Props {
|
||||
}
|
||||
|
||||
class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
static defaultProps = {
|
||||
showPermissionLevels: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = this.getCleanState();
|
||||
|
||||
@@ -22,10 +22,6 @@ export interface Props {
|
||||
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
|
||||
|
||||
class DescriptionPicker extends Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
|
||||
const selectedOption = getSelectedOption(optionsWithDesc, value);
|
||||
|
||||
@@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
|
||||
const template =
|
||||
'<move-to-folder-modal dismiss="dismiss()" ' +
|
||||
'dashboards="model.dashboards" after-save="model.afterSave()">' +
|
||||
'</move-to-folder-modal>`';
|
||||
'</move-to-folder-modal>';
|
||||
appEvents.emit('show-modal', {
|
||||
templateHtml: template,
|
||||
modalClass: 'modal--narrow',
|
||||
|
||||
@@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
|
||||
render() {
|
||||
return [
|
||||
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
||||
<img src="public/img/grafana_icon.svg" alt="graphana_logo" />
|
||||
<img src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||
</div>,
|
||||
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
||||
<i className="fa fa-bars" />
|
||||
|
||||
@@ -8,7 +8,7 @@ Array [
|
||||
onClick={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="graphana_logo"
|
||||
alt="Grafana"
|
||||
src="public/img/grafana_icon.svg"
|
||||
/>
|
||||
</div>,
|
||||
|
||||
@@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
|
||||
const actual = nodeH.getOptimizedInputEdges();
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('when linking non-existing input node with existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('non-existing', 'A');
|
||||
}).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
|
||||
it('when linking existing input node with non-existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('A', 'non-existing');
|
||||
}).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,14 @@ export class Edge {
|
||||
}
|
||||
|
||||
link(inputNode: Node, outputNode: Node) {
|
||||
if (!inputNode) {
|
||||
throw Error('inputNode is required');
|
||||
}
|
||||
|
||||
if (!outputNode) {
|
||||
throw Error('outputNode is required');
|
||||
}
|
||||
|
||||
this.unlink();
|
||||
this.inputNode = inputNode;
|
||||
this.outputNode = outputNode;
|
||||
@@ -152,7 +160,11 @@ export class Graph {
|
||||
for (let n = 0; n < inputArr.length; n++) {
|
||||
const i = inputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
inputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
inputNodes.push(n);
|
||||
} else {
|
||||
inputNodes.push(i);
|
||||
}
|
||||
@@ -161,7 +173,11 @@ export class Graph {
|
||||
for (let n = 0; n < outputArr.length; n++) {
|
||||
const i = outputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
outputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
outputNodes.push(n);
|
||||
} else {
|
||||
outputNodes.push(i);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl {
|
||||
defaults: any = {
|
||||
type: 'email',
|
||||
sendReminder: false,
|
||||
disableResolveMessage: false,
|
||||
frequency: '15m',
|
||||
settings: {
|
||||
httpMethod: 'POST',
|
||||
|
||||
@@ -21,21 +21,28 @@
|
||||
<gf-form-switch
|
||||
class="gf-form"
|
||||
label="Send on all alerts"
|
||||
label-class="width-12"
|
||||
label-class="width-14"
|
||||
checked="ctrl.model.isDefault"
|
||||
tooltip="Use this notification for all alerts">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch
|
||||
class="gf-form"
|
||||
label="Include image"
|
||||
label-class="width-12"
|
||||
label-class="width-14"
|
||||
checked="ctrl.model.settings.uploadImage"
|
||||
tooltip="Captures an image and include it in the notification">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch
|
||||
class="gf-form"
|
||||
label="Disable Resolve Message"
|
||||
label-class="width-14"
|
||||
checked="ctrl.model.disableResolveMessage"
|
||||
tooltip="Disable the resolve message [OK] that is sent when alerting state returns to false">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch
|
||||
class="gf-form"
|
||||
label="Send reminders"
|
||||
label-class="width-12"
|
||||
label-class="width-14"
|
||||
checked="ctrl.model.sendReminder"
|
||||
tooltip="Send additional notifications for triggered alerts">
|
||||
</gf-form-switch>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { makeRegions, dedupAnnotations } from './events_processing';
|
||||
export class AnnotationsSrv {
|
||||
globalAnnotationsPromise: any;
|
||||
alertStatesPromise: any;
|
||||
datasourcePromises: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
|
||||
@@ -18,6 +19,7 @@ export class AnnotationsSrv {
|
||||
clearCache() {
|
||||
this.globalAnnotationsPromise = null;
|
||||
this.alertStatesPromise = null;
|
||||
this.datasourcePromises = null;
|
||||
}
|
||||
|
||||
getAnnotations(options) {
|
||||
@@ -90,6 +92,7 @@ export class AnnotationsSrv {
|
||||
|
||||
const range = this.timeSrv.timeRange();
|
||||
const promises = [];
|
||||
const dsPromises = [];
|
||||
|
||||
for (const annotation of dashboard.annotations.list) {
|
||||
if (!annotation.enable) {
|
||||
@@ -99,10 +102,10 @@ export class AnnotationsSrv {
|
||||
if (annotation.snapshotData) {
|
||||
return this.translateQueryResult(annotation, annotation.snapshotData);
|
||||
}
|
||||
|
||||
const datasourcePromise = this.datasourceSrv.get(annotation.datasource);
|
||||
dsPromises.push(datasourcePromise);
|
||||
promises.push(
|
||||
this.datasourceSrv
|
||||
.get(annotation.datasource)
|
||||
datasourcePromise
|
||||
.then(datasource => {
|
||||
// issue query against data source
|
||||
return datasource.annotationQuery({
|
||||
@@ -122,7 +125,7 @@ export class AnnotationsSrv {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.datasourcePromises = this.$q.all(dsPromises);
|
||||
this.globalAnnotationsPromise = this.$q.all(promises);
|
||||
return this.globalAnnotationsPromise;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { StoreState } from 'app/types';
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { loadPluginDashboards } from '../../plugins/state/actions';
|
||||
import {
|
||||
DashboardAcl,
|
||||
DashboardAclDTO,
|
||||
@@ -113,3 +114,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
|
||||
await dispatch(getDashboardPermissions(dashboardId));
|
||||
};
|
||||
}
|
||||
|
||||
export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().post('/api/dashboards/import', data);
|
||||
appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
|
||||
dispatch(loadPluginDashboards());
|
||||
};
|
||||
}
|
||||
|
||||
export function removeDashboard(uri: string): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().delete(`/api/dashboards/${uri}`);
|
||||
dispatch(loadPluginDashboards());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import DashboardsTable, { Props } from './DashboardsTable';
|
||||
import { PluginDashboard } from '../../types';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
dashboards: [] as PluginDashboard[],
|
||||
onImport: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<DashboardsTable {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render table', () => {
|
||||
const wrapper = setup({
|
||||
dashboards: [
|
||||
{
|
||||
dashboardId: 0,
|
||||
description: '',
|
||||
folderId: 0,
|
||||
imported: false,
|
||||
importedRevision: 0,
|
||||
importedUri: '',
|
||||
importedUrl: '',
|
||||
path: 'dashboards/carbon_metrics.json',
|
||||
pluginId: 'graphite',
|
||||
removed: false,
|
||||
revision: 1,
|
||||
slug: '',
|
||||
title: 'Graphite Carbon Metrics',
|
||||
},
|
||||
{
|
||||
dashboardId: 0,
|
||||
description: '',
|
||||
folderId: 0,
|
||||
imported: true,
|
||||
importedRevision: 0,
|
||||
importedUri: '',
|
||||
importedUrl: '',
|
||||
path: 'dashboards/carbon_metrics.json',
|
||||
pluginId: 'graphite',
|
||||
removed: false,
|
||||
revision: 1,
|
||||
slug: '',
|
||||
title: 'Graphite Carbon Metrics',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { SFC } from 'react';
|
||||
import { PluginDashboard } from '../../types';
|
||||
|
||||
export interface Props {
|
||||
dashboards: PluginDashboard[];
|
||||
onImport: (dashboard, overwrite) => void;
|
||||
onRemove: (dashboard) => void;
|
||||
}
|
||||
|
||||
const DashboardsTable: SFC<Props> = ({ dashboards, onImport, onRemove }) => {
|
||||
function buttonText(dashboard: PluginDashboard) {
|
||||
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="filter-table">
|
||||
<tbody>
|
||||
{dashboards.map((dashboard, index) => {
|
||||
return (
|
||||
<tr key={`${dashboard.dashboardId}-${index}`}>
|
||||
<td className="width-1">
|
||||
<i className="icon-gf icon-gf-dashboard" />
|
||||
</td>
|
||||
<td>
|
||||
{dashboard.imported ? (
|
||||
<a href={dashboard.importedUrl}>{dashboard.title}</a>
|
||||
) : (
|
||||
<span>{dashboard.title}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{!dashboard.imported ? (
|
||||
<button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, false)}>
|
||||
Import
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, true)}>
|
||||
{buttonText(dashboard)}
|
||||
</button>
|
||||
)}
|
||||
{dashboard.imported && (
|
||||
<button className="btn btn-danger btn-small" onClick={() => onRemove(dashboard)}>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardsTable;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataSourceDashboards, Props } from './DataSourceDashboards';
|
||||
import { DataSource, NavModel, PluginDashboard } from 'app/types';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
navModel: {} as NavModel,
|
||||
dashboards: [] as PluginDashboard[],
|
||||
dataSource: {} as DataSource,
|
||||
pageId: 1,
|
||||
importDashboard: jest.fn(),
|
||||
loadDataSource: jest.fn(),
|
||||
loadPluginDashboards: jest.fn(),
|
||||
removeDashboard: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<DataSourceDashboards {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import DashboardTable from './DashboardsTable';
|
||||
import { DataSource, NavModel, PluginDashboard } from 'app/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { getRouteParamsId } from 'app/core/selectors/location';
|
||||
import { loadDataSource } from './state/actions';
|
||||
import { loadPluginDashboards } from '../plugins/state/actions';
|
||||
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
|
||||
import { getDataSource } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
dashboards: PluginDashboard[];
|
||||
dataSource: DataSource;
|
||||
pageId: number;
|
||||
importDashboard: typeof importDashboard;
|
||||
loadDataSource: typeof loadDataSource;
|
||||
loadPluginDashboards: typeof loadPluginDashboards;
|
||||
removeDashboard: typeof removeDashboard;
|
||||
}
|
||||
|
||||
export class DataSourceDashboards extends PureComponent<Props> {
|
||||
async componentDidMount() {
|
||||
const { loadDataSource, pageId } = this.props;
|
||||
|
||||
await loadDataSource(pageId);
|
||||
this.props.loadPluginDashboards();
|
||||
}
|
||||
|
||||
onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
|
||||
const { dataSource, importDashboard } = this.props;
|
||||
const data = {
|
||||
pluginId: dashboard.pluginId,
|
||||
path: dashboard.path,
|
||||
overwrite: overwrite,
|
||||
inputs: [],
|
||||
};
|
||||
|
||||
if (dataSource) {
|
||||
data.inputs.push({
|
||||
name: '*',
|
||||
type: 'datasource',
|
||||
pluginId: dataSource.type,
|
||||
value: dataSource.name,
|
||||
});
|
||||
}
|
||||
|
||||
importDashboard(data, dashboard.title);
|
||||
};
|
||||
|
||||
onRemove = (dashboard: PluginDashboard) => {
|
||||
this.props.removeDashboard(dashboard.importedUri);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { dashboards, navModel } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
<DashboardTable
|
||||
dashboards={dashboards}
|
||||
onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
|
||||
onRemove={dashboard => this.onRemove(dashboard)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const pageId = getRouteParamsId(state.location);
|
||||
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`),
|
||||
pageId: pageId,
|
||||
dashboards: state.plugins.dashboards,
|
||||
dataSource: getDataSource(state.dataSources, pageId),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
importDashboard,
|
||||
loadDataSource,
|
||||
loadPluginDashboards,
|
||||
removeDashboard,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards));
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { DataSource, Plugin } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
dataSource: DataSource;
|
||||
dataSourceMeta: Plugin;
|
||||
}
|
||||
interface State {
|
||||
name: string;
|
||||
}
|
||||
|
||||
enum DataSourceStates {
|
||||
Alpha = 'alpha',
|
||||
Beta = 'beta',
|
||||
}
|
||||
|
||||
export class DataSourceSettings extends PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
name: props.dataSource.name,
|
||||
};
|
||||
}
|
||||
|
||||
onNameChange = event => {
|
||||
this.setState({
|
||||
name: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onSubmit = event => {
|
||||
event.preventDefault();
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
onDelete = event => {
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
isReadyOnly() {
|
||||
return this.props.dataSource.readOnly === true;
|
||||
}
|
||||
|
||||
shouldRenderInfoBox() {
|
||||
const { state } = this.props.dataSourceMeta;
|
||||
|
||||
return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
|
||||
}
|
||||
|
||||
getInfoText() {
|
||||
const { dataSourceMeta } = this.props;
|
||||
|
||||
switch (dataSourceMeta.state) {
|
||||
case DataSourceStates.Alpha:
|
||||
return (
|
||||
'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
|
||||
' will include breaking changes.'
|
||||
);
|
||||
|
||||
case DataSourceStates.Beta:
|
||||
return (
|
||||
'This plugin is marked as being in a beta development state. This means it is in currently in active' +
|
||||
' development and could be missing important features.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="page-sub-heading">Settings</h3>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-30">
|
||||
<span className="gf-form-label width-10">Name</span>
|
||||
<input
|
||||
className="gf-form-input max-width-23"
|
||||
type="text"
|
||||
value={name}
|
||||
placeholder="name"
|
||||
onChange={this.onNameChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
|
||||
{this.isReadyOnly() && (
|
||||
<div className="grafana-info-box span8">
|
||||
This datasource was added by config and cannot be modified using the UI. Please contact your server admin
|
||||
to update this datasource.
|
||||
</div>
|
||||
)}
|
||||
<div className="gf-form-button-row">
|
||||
<button type="submit" className="btn btn-success" disabled={this.isReadyOnly()} onClick={this.onSubmit}>
|
||||
Save & Test
|
||||
</button>
|
||||
<button type="submit" className="btn btn-danger" disabled={this.isReadyOnly()} onClick={this.onDelete}>
|
||||
Delete
|
||||
</button>
|
||||
<a className="btn btn-inverse" href="datasources">
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
dataSource: state.dataSources.dataSource,
|
||||
dataSourceMeta: state.dataSources.dataSourceMeta,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(DataSourceSettings);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<tbody />
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`Render should render table 1`] = `
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
key="0-0"
|
||||
>
|
||||
<td
|
||||
className="width-1"
|
||||
>
|
||||
<i
|
||||
className="icon-gf icon-gf-dashboard"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span>
|
||||
Graphite Carbon Metrics
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"textAlign": "right",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary btn-small"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="0-1"
|
||||
>
|
||||
<td
|
||||
className="width-1"
|
||||
>
|
||||
<i
|
||||
className="icon-gf icon-gf-dashboard"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href=""
|
||||
>
|
||||
Graphite Carbon Metrics
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"textAlign": "right",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary btn-small"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-small"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-trash"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<DashboardsTable
|
||||
dashboards={Array []}
|
||||
onImport={[Function]}
|
||||
onRemove={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2,12 +2,15 @@ import { ThunkAction } from 'redux-thunk';
|
||||
import { DataSource, Plugin, StoreState } from 'app/types';
|
||||
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||
import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
|
||||
import { updateLocation } from '../../../core/actions';
|
||||
import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
|
||||
import { UpdateLocationAction } from '../../../core/actions/location';
|
||||
import { buildNavModel } from './navModel';
|
||||
|
||||
export enum ActionTypes {
|
||||
LoadDataSources = 'LOAD_DATA_SOURCES',
|
||||
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
|
||||
LoadDataSource = 'LOAD_DATA_SOURCE',
|
||||
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
|
||||
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
|
||||
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
|
||||
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
|
||||
@@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction {
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface LoadDataSourceAction {
|
||||
type: ActionTypes.LoadDataSource;
|
||||
payload: DataSource;
|
||||
}
|
||||
|
||||
export interface LoadDataSourceMetaAction {
|
||||
type: ActionTypes.LoadDataSourceMeta;
|
||||
payload: Plugin;
|
||||
}
|
||||
|
||||
const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
|
||||
type: ActionTypes.LoadDataSources,
|
||||
payload: dataSources,
|
||||
});
|
||||
|
||||
const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
|
||||
type: ActionTypes.LoadDataSource,
|
||||
payload: dataSource,
|
||||
});
|
||||
|
||||
const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
|
||||
type: ActionTypes.LoadDataSourceMeta,
|
||||
payload: dataSourceMeta,
|
||||
});
|
||||
|
||||
const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
|
||||
type: ActionTypes.LoadDataSourceTypes,
|
||||
payload: dataSourceTypes,
|
||||
@@ -69,7 +92,10 @@ export type Action =
|
||||
| SetDataSourcesLayoutModeAction
|
||||
| UpdateLocationAction
|
||||
| LoadDataSourceTypesAction
|
||||
| SetDataSourceTypeSearchQueryAction;
|
||||
| SetDataSourceTypeSearchQueryAction
|
||||
| LoadDataSourceAction
|
||||
| UpdateNavIndexAction
|
||||
| LoadDataSourceMetaAction;
|
||||
|
||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||
|
||||
@@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadDataSource(id: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
|
||||
const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`);
|
||||
dispatch(dataSourceLoaded(dataSource));
|
||||
dispatch(dataSourceMetaLoaded(pluginInfo));
|
||||
dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
|
||||
};
|
||||
}
|
||||
|
||||
export function addDataSource(plugin: Plugin): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
await dispatch(loadDataSources());
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -644,7 +644,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
/>
|
||||
)}
|
||||
{supportsTable && showingTable ? (
|
||||
<Table className="m-t-3" data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
|
||||
<div className="panel-container">
|
||||
<Table data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
|
||||
</div>
|
||||
) : null}
|
||||
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
|
||||
</main>
|
||||
|
||||
@@ -96,11 +96,14 @@ describe('PromQueryField typeahead handling', () => {
|
||||
|
||||
it('returns label suggestions on label context but leaves out labels that already exist', () => {
|
||||
const instance = shallow(
|
||||
<PromQueryField {...defaultProps} labelKeys={{ '{job="foo"}': ['bar', 'job'] }} />
|
||||
<PromQueryField
|
||||
{...defaultProps}
|
||||
labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
|
||||
/>
|
||||
).instance() as PromQueryField;
|
||||
const value = Plain.deserialize('{job="foo",}');
|
||||
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
|
||||
const range = value.selection.merge({
|
||||
anchorOffset: 11,
|
||||
anchorOffset: 36,
|
||||
});
|
||||
const valueWithSelection = value.change().select(range).value;
|
||||
const result = instance.getTypeahead({
|
||||
@@ -113,6 +116,33 @@ describe('PromQueryField typeahead handling', () => {
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
});
|
||||
|
||||
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
|
||||
const instance = shallow(
|
||||
<PromQueryField
|
||||
{...defaultProps}
|
||||
labelKeys={{ '{}': ['label'] }}
|
||||
labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
|
||||
/>
|
||||
).instance() as PromQueryField;
|
||||
const value = Plain.deserialize('{label!=}');
|
||||
const range = value.selection.merge({ anchorOffset: 8 });
|
||||
const valueWithSelection = value.change().select(range).value;
|
||||
const result = instance.getTypeahead({
|
||||
text: '!=',
|
||||
prefix: '',
|
||||
wrapperClasses: ['context-labels'],
|
||||
labelKey: 'label',
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-label-values');
|
||||
expect(result.suggestions).toEqual([
|
||||
{
|
||||
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
|
||||
label: 'Label values for "label"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns a refresher on label context and unavailable metric', () => {
|
||||
const instance = shallow(
|
||||
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
|
||||
|
||||
@@ -111,7 +111,7 @@ export function willApplySuggestion(
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
||||
if (!typeaheadText.match(/^(!?=~?"|")/)) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
@@ -421,7 +421,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
const containsMetric = selector.indexOf('__name__=') > -1;
|
||||
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
|
||||
|
||||
if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
|
||||
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
|
||||
// Label values
|
||||
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
|
||||
const labelValues = this.state.labelValues[selector][labelKey];
|
||||
|
||||
@@ -228,7 +228,13 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
const offset = range.startOffset;
|
||||
const text = selection.anchorNode.textContent;
|
||||
let prefix = text.substr(0, offset);
|
||||
if (cleanText) {
|
||||
|
||||
// Label values could have valid characters erased if `cleanText()` is
|
||||
// blindly applied, which would undesirably interfere with suggestions
|
||||
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
|
||||
if (labelValueMatch) {
|
||||
prefix = labelValueMatch[1];
|
||||
} else if (cleanText) {
|
||||
prefix = cleanText(prefix);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +1,55 @@
|
||||
import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
|
||||
import TableModel from 'app/core/table_model';
|
||||
|
||||
const EMPTY_TABLE = new TableModel();
|
||||
|
||||
interface TableProps {
|
||||
className?: string;
|
||||
data: TableModel;
|
||||
loading: boolean;
|
||||
onClickCell?: (columnKey: string, rowValue: string) => void;
|
||||
}
|
||||
|
||||
interface SFCCellProps {
|
||||
columnIndex: number;
|
||||
onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
|
||||
rowIndex: number;
|
||||
table: TableModel;
|
||||
value: string;
|
||||
function prepareRows(rows, columnNames) {
|
||||
return rows.map(cells => _.zipObject(columnNames, cells));
|
||||
}
|
||||
|
||||
function Cell(props: SFCCellProps) {
|
||||
const { columnIndex, rowIndex, table, value, onClickCell } = props;
|
||||
const column = table.columns[columnIndex];
|
||||
if (column && column.filterable && onClickCell) {
|
||||
const onClick = event => {
|
||||
event.preventDefault();
|
||||
onClickCell(column.text, value, columnIndex, rowIndex, table);
|
||||
export default class Table extends PureComponent<TableProps> {
|
||||
getCellProps = (state, rowInfo, column) => {
|
||||
return {
|
||||
onClick: () => {
|
||||
const columnKey = column.Header;
|
||||
const rowValue = rowInfo.row[columnKey];
|
||||
this.props.onClickCell(columnKey, rowValue);
|
||||
},
|
||||
};
|
||||
return (
|
||||
<td>
|
||||
<a className="link" onClick={onClick}>
|
||||
{value}
|
||||
</a>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return <td>{value}</td>;
|
||||
}
|
||||
};
|
||||
|
||||
export default class Table extends PureComponent<TableProps, {}> {
|
||||
render() {
|
||||
const { className = '', data, loading, onClickCell } = this.props;
|
||||
const { data, loading } = this.props;
|
||||
const tableModel = data || EMPTY_TABLE;
|
||||
if (!loading && data && data.rows.length === 0) {
|
||||
return (
|
||||
<table className={`${className} filter-table`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="muted">The queries returned no data for a table.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
const columnNames = tableModel.columns.map(({ text }) => text);
|
||||
const columns = tableModel.columns.map(({ filterable, text }) => ({
|
||||
Header: text,
|
||||
accessor: text,
|
||||
show: text !== 'Time',
|
||||
Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
|
||||
}));
|
||||
const noDataText = data ? 'The queries returned no data for a table.' : '';
|
||||
|
||||
return (
|
||||
<table className={`${className} filter-table`}>
|
||||
<thead>
|
||||
<tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableModel.rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((value, j) => (
|
||||
<Cell
|
||||
key={j}
|
||||
columnIndex={j}
|
||||
rowIndex={i}
|
||||
value={String(value)}
|
||||
table={data}
|
||||
onClickCell={onClickCell}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ReactTable
|
||||
columns={columns}
|
||||
data={tableModel.rows}
|
||||
getTdProps={this.getCellProps}
|
||||
loading={loading}
|
||||
minRows={0}
|
||||
noDataText={noDataText}
|
||||
resolveData={data => prepareRows(data, columnNames)}
|
||||
showPagination={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body" ng-if="ctrl.datasourceMeta">
|
||||
|
||||
<dashboard-import-list plugin="ctrl.datasourceMeta" datasource="ctrl.current"></dashboard-import-list>
|
||||
|
||||
</div>
|
||||
@@ -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<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||
|
||||
@@ -49,3 +61,12 @@ export function loadPlugins(): ThunkResult<void> {
|
||||
dispatch(pluginsLoaded(result));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPluginDashboards(): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
const dataSourceType = getStore().dataSources.dataSource.type;
|
||||
|
||||
const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`);
|
||||
dispatch(pluginDashboardsLoaded(response));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -291,9 +294,11 @@ export class VariableSrv {
|
||||
createGraph() {
|
||||
const g = new Graph();
|
||||
|
||||
this.variables.forEach(v1 => {
|
||||
g.createNode(v1.name);
|
||||
this.variables.forEach(v => {
|
||||
g.createNode(v.name);
|
||||
});
|
||||
|
||||
this.variables.forEach(v1 => {
|
||||
this.variables.forEach(v2 => {
|
||||
if (v1 === v2) {
|
||||
return;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-13">Default Region</label>
|
||||
<div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
|
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['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']"></select>
|
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ctrl.regions"></select>
|
||||
<info-popover mode="right-absolute">
|
||||
Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
|
||||
</info-popover>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<time_column> as time,
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Encrypt</label>
|
||||
<div class="gf-form-select-wrapper max-width-15 gf-form-select-wrapper--has-help-icon">
|
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.encrypt" ng-options="mode for mode in ['disable', 'false', 'true']" ng-init="ctrl.current.jsonData.encrypt"></select>
|
||||
<info-popover mode="right-absolute">
|
||||
Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server.
|
||||
<ul>
|
||||
<li><i>disable</i> - Data sent between client and server is not encrypted.</li>
|
||||
<li><i>false</i> - Data sent between client and server is not encrypted beyond the login packet. (default)</li>
|
||||
<li><i>true</i> - Data sent between client and server is encrypted.</li>
|
||||
</ul>
|
||||
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.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b>Connection limits</b>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -156,7 +156,16 @@ class GraphCtrl extends MetricsPanelCtrl {
|
||||
panel: this.panel,
|
||||
range: this.range,
|
||||
});
|
||||
return super.issueQueries(datasource);
|
||||
|
||||
/* Wait for annotationSrv requests to get datasources to
|
||||
* resolve before issuing queries. This allows the annotations
|
||||
* service to fire annotations queries before graph queries
|
||||
* (but not wait for completion). This resolves
|
||||
* issue 11806.
|
||||
*/
|
||||
return this.annotationsSrv.datasourcePromises.then(r => {
|
||||
return super.issueQueries(datasource);
|
||||
});
|
||||
}
|
||||
|
||||
zoomOut(evt) {
|
||||
|
||||
@@ -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: '<react-container />',
|
||||
resolve: {
|
||||
component: () => DataSourceDashboards,
|
||||
},
|
||||
})
|
||||
.when('/datasources/new', {
|
||||
template: '<react-container />',
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
DataQueryOptions,
|
||||
} from './series';
|
||||
import { PanelProps } from './panel';
|
||||
import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
|
||||
|
||||
export {
|
||||
Team,
|
||||
@@ -69,6 +69,7 @@ export {
|
||||
DataQuery,
|
||||
DataQueryResponse,
|
||||
DataQueryOptions,
|
||||
PluginDashboard,
|
||||
};
|
||||
|
||||
export interface StoreState {
|
||||
|
||||
@@ -62,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[];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
font-size: $font-size-root;
|
||||
font-family: $font-family-monospace;
|
||||
height: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.slate-query-field-wrapper {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user