Merge branch 'master' into new-data-source-as-separate-page

This commit is contained in:
Peter Holmberg 2018-10-05 13:15:26 +02:00
commit e1c77f634d
126 changed files with 6153 additions and 1578 deletions

View File

@ -158,14 +158,18 @@ jobs:
name: sha-sum packages name: sha-sum packages
command: 'go run build.go sha-dist' command: 'go run build.go sha-dist'
- run: - run:
name: Build Grafana.com publisher name: Build Grafana.com master publisher
command: 'go build -o scripts/publish scripts/build/publish.go' command: 'go build -o scripts/publish scripts/build/publish.go'
- run:
name: Build Grafana.com release publisher
command: 'cd scripts/build/release_publisher && go build -o release_publisher .'
- persist_to_workspace: - persist_to_workspace:
root: . root: .
paths: paths:
- dist/grafana* - dist/grafana*
- scripts/*.sh - scripts/*.sh
- scripts/publish - scripts/publish
- scripts/build/release_publisher/release_publisher
build: build:
docker: docker:
@ -299,8 +303,8 @@ jobs:
name: deploy to s3 name: deploy to s3
command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release' command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
- run: - run:
name: Trigger Windows build name: Deploy to Grafana.com
command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release' command: './scripts/build/publish.sh'
workflows: workflows:
version: 2 version: 2

View File

@ -1,22 +0,0 @@
Follow the setup guide in README.md
### Rebuild frontend assets on source change
```
yarn watch
```
### Rerun tests on source change
```
yarn jest
```
### Run tests for backend assets before commit
```
test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
```
### Run tests for frontend assets before commit
```
yarn test
go test -v ./pkg/...
```

2
.gitignore vendored
View File

@ -73,3 +73,5 @@ debug.test
/devenv/bulk-dashboards/*.json /devenv/bulk-dashboards/*.json
/devenv/bulk_alerting_dashboards/*.json /devenv/bulk_alerting_dashboards/*.json
/scripts/build/release_publisher/release_publisher

View File

@ -1,5 +1,17 @@
# 5.4.0 (unreleased)
### Minor
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
# 5.3.0 (unreleased) # 5.3.0 (unreleased)
# 5.3.0-beta3 (2018-10-03)
* **Stackdriver**: Fix for missing ngInject [#13511](https://github.com/grafana/grafana/pull/13511)
* **Permissions**: Fix for broken permissions selector [#13507](https://github.com/grafana/grafana/issues/13507)
* **Alerting**: Alert reminders deduping not working as expected when running multiple Grafana instances [#13492](https://github.com/grafana/grafana/issues/13492)
# 5.3.0-beta2 (2018-10-01) # 5.3.0-beta2 (2018-10-01)
### New Features ### New Features

56
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,56 @@
# Contributing
Grafana uses GitHub to manage contributions.
Contributions take the form of pull requests that will be reviewed by the core team.
* If you are a new contributor see: [Steps to Contribute](#steps-to-contribute)
* If you have a trivial fix or improvement, go ahead and create a pull request.
* If you plan to do something more involved, discuss your idea on the respective [issue](https://github.com/grafana/grafana/issues) or create a [new issue](https://github.com/grafana/grafana/issues/new) if it does not exist. This will avoid unnecessary work and surely give you and us a good deal of inspiration.
## Steps to Contribute
Should you wish to work on a GitHub issue, check first if it is not already assigned to someone. If it is free, you claim it by commenting on the issue that you want to work on it. This is to prevent duplicated efforts from contributors on the same issue.
Please check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are good for getting started. If you have questions about one of the issues, with or without the tag, please comment on them and one of the core team or the original poster will clarify it.
## Setup
Follow the setup guide in README.md
### Rebuild frontend assets on source change
```
yarn watch
```
### Rerun tests on source change
```
yarn jest
```
### Run tests for backend assets before commit
```
test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
```
### Run tests for frontend assets before commit
```
yarn test
go test -v ./pkg/...
```
## Pull Request Checklist
* Branch from the master branch and, if needed, rebase to the current master branch before submitting your pull request. If it doesn't merge cleanly with master you may be asked to rebase your changes.
* Commits should be as small as possible, while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests).
* If your patch is not getting reviewed or you need a specific person to review it, you can @-reply a reviewer asking for a review in the pull request or a comment.
* Add tests relevant to the fixed bug or new feature.

8
Gopkg.lock generated
View File

@ -19,6 +19,12 @@
packages = ["."] packages = ["."]
revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520" revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
[[projects]]
branch = "master"
name = "github.com/VividCortex/mysqlerr"
packages = ["."]
revision = "6c6b55f8796f578c870b7e19bafb16103bc40095"
[[projects]] [[projects]]
name = "github.com/aws/aws-sdk-go" name = "github.com/aws/aws-sdk-go"
packages = [ packages = [
@ -673,6 +679,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3" inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -203,3 +203,7 @@ ignored = [
[[constraint]] [[constraint]]
name = "github.com/denisenkom/go-mssqldb" name = "github.com/denisenkom/go-mssqldb"
revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
[[constraint]]
name = "github.com/VividCortex/mysqlerr"
branch = "master"

File diff suppressed because it is too large Load Diff

View File

@ -8,18 +8,33 @@ services:
volumes: volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro - /var/run/docker.sock:/tmp/docker.sock:ro
mysql: db:
image: mysql image: mysql
environment: environment:
MYSQL_ROOT_PASSWORD: rootpass MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: grafana MYSQL_DATABASE: grafana
MYSQL_USER: grafana MYSQL_USER: grafana
MYSQL_PASSWORD: password MYSQL_PASSWORD: password
ports:
- 3306
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s timeout: 10s
retries: 10 retries: 10
# db:
# image: postgres:9.3
# environment:
# POSTGRES_DATABASE: grafana
# POSTGRES_USER: grafana
# POSTGRES_PASSWORD: password
# ports:
# - 5432
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -d grafana -U grafana"]
# timeout: 10s
# retries: 10
grafana: grafana:
image: grafana/grafana:dev image: grafana/grafana:dev
volumes: volumes:
@ -27,17 +42,23 @@ services:
environment: environment:
- VIRTUAL_HOST=grafana.loc - VIRTUAL_HOST=grafana.loc
- GF_SERVER_ROOT_URL=http://grafana.loc - GF_SERVER_ROOT_URL=http://grafana.loc
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=mysql:3306
- GF_DATABASE_NAME=grafana - GF_DATABASE_NAME=grafana
- GF_DATABASE_USER=grafana - GF_DATABASE_USER=grafana
- GF_DATABASE_PASSWORD=password - GF_DATABASE_PASSWORD=password
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=db:3306
- GF_SESSION_PROVIDER=mysql - GF_SESSION_PROVIDER=mysql
- GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(mysql:3306)/grafana?allowNativePasswords=true - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
# - GF_DATABASE_TYPE=postgres
# - GF_DATABASE_HOST=db:5432
# - GF_DATABASE_SSL_MODE=disable
# - GF_SESSION_PROVIDER=postgres
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
ports: ports:
- 3000 - 3000
depends_on: depends_on:
mysql: db:
condition: service_healthy condition: service_healthy
prometheus: prometheus:
@ -54,4 +75,4 @@ services:
# environment: # environment:
# - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/ # - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
# ports: # ports:
# - 9104 # - 9104

View File

@ -127,10 +127,13 @@ Another way is put a webserver like Nginx or Apache in front of Grafana and have
### protocol ### protocol
`http` or `https` `http`,`https` or `socket`
> **Note** Grafana versions earlier than 3.0 are vulnerable to [POODLE](https://en.wikipedia.org/wiki/POODLE). So we strongly recommend to upgrade to 3.x or use a reverse proxy for ssl termination. > **Note** Grafana versions earlier than 3.0 are vulnerable to [POODLE](https://en.wikipedia.org/wiki/POODLE). So we strongly recommend to upgrade to 3.x or use a reverse proxy for ssl termination.
### socket
Path where the socket should be created when `protocol=socket`. Please make sure that Grafana has appropriate permissions.
### domain ### domain
This setting is only used in as a part of the `root_url` setting (see below). Important if you This setting is only used in as a part of the `root_url` setting (see below). Important if you

View File

@ -4,7 +4,7 @@
"company": "Grafana Labs" "company": "Grafana Labs"
}, },
"name": "grafana", "name": "grafana",
"version": "5.3.0-pre1", "version": "5.4.0-pre1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "http://github.com/grafana/grafana.git" "url": "http://github.com/grafana/grafana.git"

View File

@ -51,7 +51,21 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
return return
} }
proxyPath := c.Params("*") // macaron does not include trailing slashes when resolving a wildcard path
proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath) proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
proxy.HandleRequest() proxy.HandleRequest()
} }
// ensureProxyPathTrailingSlash Check for a trailing slash in original path and makes
// sure that a trailing slash is added to proxy path, if not already exists.
func ensureProxyPathTrailingSlash(originalPath, proxyPath string) string {
if len(proxyPath) > 1 {
if originalPath[len(originalPath)-1] == '/' && proxyPath[len(proxyPath)-1] != '/' {
return proxyPath + "/"
}
}
return proxyPath
}

19
pkg/api/dataproxy_test.go Normal file
View File

@ -0,0 +1,19 @@
package api
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestDataProxy(t *testing.T) {
Convey("Data proxy test", t, func() {
Convey("Should append trailing slash to proxy path if original path has a trailing slash", func() {
So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range/", "api/v1/query_range/"), ShouldEqual, "api/v1/query_range/")
})
Convey("Should not append trailing slash to proxy path if original path doesn't have a trailing slash", func() {
So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range", "api/v1/query_range"), ShouldEqual, "api/v1/query_range")
})
})
}

View File

@ -362,6 +362,23 @@ func TestDSRouteRule(t *testing.T) {
}) })
}) })
Convey("When proxying a custom datasource", func() {
plugin := &plugins.DataSourcePlugin{}
ds := &m.DataSource{
Type: "custom-datasource",
Url: "http://host/root/",
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
proxy.getDirector()(req)
Convey("Shoudl keep user request (including trailing slash)", func() {
So(req.URL.String(), ShouldEqual, "http://host/root/path/to/folder/")
})
})
}) })
} }

View File

@ -75,7 +75,7 @@ type Alert struct {
EvalData *simplejson.Json EvalData *simplejson.Json
NewStateDate time.Time NewStateDate time.Time
StateChanges int StateChanges int64
Created time.Time Created time.Time
Updated time.Time Updated time.Time
@ -156,7 +156,7 @@ type SetAlertStateCommand struct {
Error string Error string
EvalData *simplejson.Json EvalData *simplejson.Json
Timestamp time.Time Result Alert
} }
//Queries //Queries

View File

@ -8,8 +8,18 @@ import (
) )
var ( var (
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified") ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
ErrJournalingNotFound = errors.New("alert notification journaling not found") ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.")
)
type AlertNotificationStateType string
var (
AlertNotificationStatePending = AlertNotificationStateType("pending")
AlertNotificationStateCompleted = AlertNotificationStateType("completed")
AlertNotificationStateUnknown = AlertNotificationStateType("unknown")
) )
type AlertNotification struct { type AlertNotification struct {
@ -76,33 +86,34 @@ type GetAllAlertNotificationsQuery struct {
Result []*AlertNotification Result []*AlertNotification
} }
type AlertNotificationJournal struct { type AlertNotificationState struct {
Id int64 Id int64
OrgId int64 OrgId int64
AlertId int64 AlertId int64
NotifierId int64 NotifierId int64
SentAt int64 State AlertNotificationStateType
Success bool Version int64
UpdatedAt int64
AlertRuleStateUpdatedVersion int64
} }
type RecordNotificationJournalCommand struct { type SetAlertNotificationStateToPendingCommand struct {
OrgId int64 Id int64
AlertId int64 AlertRuleStateUpdatedVersion int64
NotifierId int64 Version int64
SentAt int64
Success bool ResultVersion int64
} }
type GetLatestNotificationQuery struct { type SetAlertNotificationStateToCompleteCommand struct {
Id int64
Version int64
}
type GetOrCreateNotificationStateQuery struct {
OrgId int64 OrgId int64
AlertId int64 AlertId int64
NotifierId int64 NotifierId int64
Result []AlertNotificationJournal Result *AlertNotificationState
}
type CleanNotificationJournalCommand struct {
OrgId int64
AlertId int64
NotifierId int64
} }

View File

@ -3,6 +3,8 @@ package alerting
import ( import (
"context" "context"
"time" "time"
"github.com/grafana/grafana/pkg/models"
) )
type EvalHandler interface { type EvalHandler interface {
@ -20,7 +22,7 @@ type Notifier interface {
NeedsImage() bool NeedsImage() bool
// ShouldNotify checks this evaluation should send an alert notification // ShouldNotify checks this evaluation should send an alert notification
ShouldNotify(ctx context.Context, evalContext *EvalContext) bool ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
GetNotifierId() int64 GetNotifierId() int64
GetIsDefault() bool GetIsDefault() bool
@ -28,11 +30,16 @@ type Notifier interface {
GetFrequency() time.Duration GetFrequency() time.Duration
} }
type NotifierSlice []Notifier type notifierState struct {
notifier Notifier
state *models.AlertNotificationState
}
func (notifiers NotifierSlice) ShouldUploadImage() bool { type notifierStateSlice []*notifierState
for _, notifier := range notifiers {
if notifier.NeedsImage() { func (notifiers notifierStateSlice) ShouldUploadImage() bool {
for _, ns := range notifiers {
if ns.notifier.NeedsImage() {
return true return true
} }
} }

View File

@ -1,10 +1,8 @@
package alerting package alerting
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/imguploader" "github.com/grafana/grafana/pkg/components/imguploader"
@ -41,61 +39,78 @@ type notificationService struct {
} }
func (n *notificationService) SendIfNeeded(context *EvalContext) error { func (n *notificationService) SendIfNeeded(context *EvalContext) error {
notifiers, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context) notifierStates, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
if err != nil { if err != nil {
return err return err
} }
if len(notifiers) == 0 { if len(notifierStates) == 0 {
return nil return nil
} }
if notifiers.ShouldUploadImage() { if notifierStates.ShouldUploadImage() {
if err = n.uploadImage(context); err != nil { if err = n.uploadImage(context); err != nil {
n.log.Error("Failed to upload alert panel image.", "error", err) n.log.Error("Failed to upload alert panel image.", "error", err)
} }
} }
return n.sendNotifications(context, notifiers) return n.sendNotifications(context, notifierStates)
} }
func (n *notificationService) sendNotifications(evalContext *EvalContext, notifiers []Notifier) error { func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
for _, notifier := range notifiers { notifier := notifierState.notifier
not := notifier
err := bus.InTransaction(evalContext.Ctx, func(ctx context.Context) error { n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault())
n.log.Debug("trying to send notification", "id", not.GetNotifierId()) metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
// Verify that we can send the notification again err := notifier.Notify(evalContext)
// but this time within the same transaction.
if !evalContext.IsTestRun && !not.ShouldNotify(ctx, evalContext) {
return nil
}
n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault()) if err != nil {
metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc() n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
}
//send notification if evalContext.IsTestRun {
success := not.Notify(evalContext) == nil return nil
}
if evalContext.IsTestRun { cmd := &m.SetAlertNotificationStateToCompleteCommand{
return nil Id: notifierState.state.Id,
} Version: notifierState.state.Version,
}
//write result to db. return bus.DispatchCtx(evalContext.Ctx, cmd)
cmd := &m.RecordNotificationJournalCommand{ }
OrgId: evalContext.Rule.OrgId,
AlertId: evalContext.Rule.Id,
NotifierId: not.GetNotifierId(),
SentAt: time.Now().Unix(),
Success: success,
}
return bus.DispatchCtx(ctx, cmd) func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error {
}) if !evalContext.IsTestRun {
setPendingCmd := &m.SetAlertNotificationStateToPendingCommand{
Id: notifierState.state.Id,
Version: notifierState.state.Version,
AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges,
}
err := bus.DispatchCtx(evalContext.Ctx, setPendingCmd)
if err == m.ErrAlertNotificationStateVersionConflict {
return nil
}
if err != nil { if err != nil {
n.log.Error("failed to send notification", "id", not.GetNotifierId()) return err
}
// We need to update state version to be able to log
// unexpected version conflicts when marking notifications as ok
notifierState.state.Version = setPendingCmd.ResultVersion
}
return n.sendAndMarkAsComplete(evalContext, notifierState)
}
func (n *notificationService) sendNotifications(evalContext *EvalContext, notifierStates notifierStateSlice) error {
for _, notifierState := range notifierStates {
err := n.sendNotification(evalContext, notifierState)
if err != nil {
n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err)
} }
} }
@ -142,22 +157,38 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
return nil return nil
} }
func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (NotifierSlice, error) { func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) {
query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds} query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
if err := bus.Dispatch(query); err != nil { if err := bus.Dispatch(query); err != nil {
return nil, err return nil, err
} }
var result []Notifier var result notifierStateSlice
for _, notification := range query.Result { for _, notification := range query.Result {
not, err := n.createNotifierFor(notification) not, err := n.createNotifierFor(notification)
if err != nil { if err != nil {
return nil, err n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
continue
} }
if not.ShouldNotify(evalContext.Ctx, evalContext) { query := &m.GetOrCreateNotificationStateQuery{
result = append(result, not) NotifierId: notification.Id,
AlertId: evalContext.Rule.Id,
OrgId: evalContext.Rule.OrgId,
}
err = bus.DispatchCtx(evalContext.Ctx, query)
if err != nil {
n.log.Error("Could not get notification state.", "notifier", notification.Id, "error", err)
continue
}
if not.ShouldNotify(evalContext.Ctx, evalContext, query.Result) {
result = append(result, &notifierState{
notifier: not,
state: query.Result,
})
} }
} }

View File

@ -46,7 +46,7 @@ type AlertmanagerNotifier struct {
log log.Logger log log.Logger
} }
func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext) bool { func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext, notificationState *m.AlertNotificationState) bool {
this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState) this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState)
// Do not notify when we become OK for the first time. // Do not notify when we become OK for the first time.

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"time" "time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
@ -46,56 +45,47 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
} }
} }
func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, journals []models.AlertNotificationJournal) bool { // ShouldNotify checks this evaluation should send an alert notification
func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalContext, notiferState *models.AlertNotificationState) bool {
// Only notify on state change. // Only notify on state change.
if context.PrevAlertState == context.Rule.State && !sendReminder { if context.PrevAlertState == context.Rule.State && !n.SendReminder {
return false return false
} }
// get last successfully sent notification if context.PrevAlertState == context.Rule.State && n.SendReminder {
lastNotify := time.Time{} // Do not notify if interval has not elapsed
for _, j := range journals { lastNotify := time.Unix(notiferState.UpdatedAt, 0)
if j.Success { if notiferState.UpdatedAt != 0 && lastNotify.Add(n.Frequency).After(time.Now()) {
lastNotify = time.Unix(j.SentAt, 0) return false
break }
// Do not notify if alert state is OK or pending even on repeated notify
if context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending {
return false
} }
} }
// Do not notify if interval has not elapsed
if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
return false
}
// Do not notify if alert state if OK or pending even on repeated notify
if sendReminder && (context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending) {
return false
}
// Do not notify when we become OK for the first time. // Do not notify when we become OK for the first time.
if (context.PrevAlertState == models.AlertStatePending) && (context.Rule.State == models.AlertStateOK) { if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK {
return false return false
} }
// Do not notify when we OK -> Pending
if context.PrevAlertState == models.AlertStateOK && context.Rule.State == models.AlertStatePending {
return false
}
// Do not notifu if state pending and it have been updated last minute
if notiferState.State == models.AlertNotificationStatePending {
lastUpdated := time.Unix(notiferState.UpdatedAt, 0)
if lastUpdated.Add(1 * time.Minute).After(time.Now()) {
return false
}
}
return true return true
} }
// ShouldNotify checks this evaluation should send an alert notification
func (n *NotifierBase) ShouldNotify(ctx context.Context, c *alerting.EvalContext) bool {
cmd := &models.GetLatestNotificationQuery{
OrgId: c.Rule.OrgId,
AlertId: c.Rule.Id,
NotifierId: n.Id,
}
err := bus.DispatchCtx(ctx, cmd)
if err != nil {
n.log.Error("Could not determine last time alert notifier fired", "Alert name", c.Rule.Name, "Error", err)
return false
}
return defaultShouldNotify(c, n.SendReminder, n.Frequency, cmd.Result)
}
func (n *NotifierBase) GetType() string { func (n *NotifierBase) GetType() string {
return n.Type return n.Type
} }

View File

@ -2,12 +2,9 @@ package notifiers
import ( import (
"context" "context"
"errors"
"testing" "testing"
"time" "time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/alerting"
@ -23,34 +20,34 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState m.AlertStateType newState m.AlertStateType
sendReminder bool sendReminder bool
frequency time.Duration frequency time.Duration
journals []m.AlertNotificationJournal state *m.AlertNotificationState
expect bool expect bool
}{ }{
{ {
name: "pending -> ok should not trigger an notification", name: "pending -> ok should not trigger an notification",
newState: m.AlertStatePending, newState: m.AlertStateOK,
prevState: m.AlertStateOK, prevState: m.AlertStatePending,
sendReminder: false, sendReminder: false,
journals: []m.AlertNotificationJournal{}, state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
{ {
name: "ok -> alerting should trigger an notification", name: "ok -> alerting should trigger an notification",
newState: m.AlertStateOK, newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
journals: []m.AlertNotificationJournal{}, state: &m.AlertNotificationState{},
expect: true, expect: true,
}, },
{ {
name: "ok -> pending should not trigger an notification", name: "ok -> pending should not trigger an notification",
newState: m.AlertStateOK, newState: m.AlertStatePending,
prevState: m.AlertStatePending, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
journals: []m.AlertNotificationJournal{}, state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
@ -59,100 +56,100 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: false, sendReminder: false,
journals: []m.AlertNotificationJournal{}, state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
{
name: "ok -> alerting should trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
sendReminder: true,
journals: []m.AlertNotificationJournal{},
expect: true,
},
{ {
name: "ok -> ok with reminder should not trigger an notification", name: "ok -> ok with reminder should not trigger an notification",
newState: m.AlertStateOK, newState: m.AlertStateOK,
prevState: m.AlertStateOK, prevState: m.AlertStateOK,
sendReminder: true, sendReminder: true,
journals: []m.AlertNotificationJournal{}, state: &m.AlertNotificationState{},
expect: false, expect: false,
}, },
{ {
name: "alerting -> alerting with reminder and no journaling should trigger", name: "alerting -> ok should trigger an notification",
newState: m.AlertStateAlerting, newState: m.AlertStateOK,
prevState: m.AlertStateAlerting, prevState: m.AlertStateAlerting,
frequency: time.Minute * 10, sendReminder: false,
sendReminder: true, state: &m.AlertNotificationState{},
journals: []m.AlertNotificationJournal{},
expect: true, expect: true,
}, },
{ {
name: "alerting -> alerting with reminder and successful recent journal event should not trigger", name: "alerting -> ok should trigger an notification when reminders enabled",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
expect: true,
},
{
name: "alerting -> alerting with reminder and no state should trigger",
newState: m.AlertStateAlerting, newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting, prevState: m.AlertStateAlerting,
frequency: time.Minute * 10, frequency: time.Minute * 10,
sendReminder: true, sendReminder: true,
journals: []m.AlertNotificationJournal{ state: &m.AlertNotificationState{},
{SentAt: tnow.Add(-time.Minute).Unix(), Success: true},
}, expect: true,
},
{
name: "alerting -> alerting with reminder and last notification sent 1 minute ago should not trigger",
newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
expect: false, expect: false,
}, },
{ {
name: "alerting -> alerting with reminder and failed recent journal event should trigger", name: "alerting -> alerting with reminder and last notifciation sent 11 minutes ago should trigger",
newState: m.AlertStateAlerting, newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting, prevState: m.AlertStateAlerting,
frequency: time.Minute * 10, frequency: time.Minute * 10,
sendReminder: true, sendReminder: true,
expect: true, state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
journals: []m.AlertNotificationJournal{
{SentAt: tnow.Add(-time.Minute).Unix(), Success: false}, // recent failed notification expect: true,
{SentAt: tnow.Add(-time.Hour).Unix(), Success: true}, // old successful notification },
}, {
name: "OK -> alerting with notifciation state pending and updated 30 seconds ago should not trigger",
newState: m.AlertStateAlerting,
prevState: m.AlertStateOK,
state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-30 * time.Second).Unix()},
expect: false,
},
{
name: "OK -> alerting with notifciation state pending and updated 2 minutes ago should trigger",
newState: m.AlertStateAlerting,
prevState: m.AlertStateOK,
state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()},
expect: true,
}, },
} }
for _, tc := range tcs { for _, tc := range tcs {
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: tc.newState, State: tc.prevState,
}) })
evalContext.Rule.State = tc.prevState evalContext.Rule.State = tc.newState
if defaultShouldNotify(evalContext, true, tc.frequency, tc.journals) != tc.expect { nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency}
if nb.ShouldNotify(evalContext.Ctx, evalContext, tc.state) != tc.expect {
t.Errorf("failed test %s.\n expected \n%+v \nto return: %v", tc.name, tc, tc.expect) t.Errorf("failed test %s.\n expected \n%+v \nto return: %v", tc.name, tc, tc.expect)
} }
} }
} }
func TestShouldNotifyWhenNoJournalingIsFound(t *testing.T) {
Convey("base notifier", t, func() {
bus.ClearBusHandlers()
notifier := NewNotifierBase(&m.AlertNotification{
Id: 1,
Name: "name",
Type: "email",
Settings: simplejson.New(),
})
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{})
Convey("should not notify query returns error", func() {
bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error {
return errors.New("some kind of error unknown error")
})
if notifier.ShouldNotify(context.Background(), evalContext) {
t.Errorf("should not send notifications when query returns error")
}
})
})
}
func TestBaseNotifier(t *testing.T) { func TestBaseNotifier(t *testing.T) {
Convey("default constructor for notifiers", t, func() { Convey("default constructor for notifiers", t, func() {
bJson := simplejson.New() bJson := simplejson.New()

View File

@ -1,6 +1,7 @@
package notifiers package notifiers
import ( import (
"context"
"testing" "testing"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -52,11 +53,12 @@ func TestTelegramNotifier(t *testing.T) {
}) })
Convey("generateCaption should generate a message with all pertinent details", func() { Convey("generateCaption should generate a message with all pertinent details", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ evalContext := alerting.NewEvalContext(context.Background(),
Name: "This is an alarm", &alerting.Rule{
Message: "Some kind of message.", Name: "This is an alarm",
State: m.AlertStateOK, Message: "Some kind of message.",
}) State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "") caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
So(len(caption), ShouldBeLessThanOrEqualTo, 200) So(len(caption), ShouldBeLessThanOrEqualTo, 200)
@ -68,11 +70,12 @@ func TestTelegramNotifier(t *testing.T) {
Convey("When generating a message", func() { Convey("When generating a message", func() {
Convey("URL should be skipped if it's too long", func() { Convey("URL should be skipped if it's too long", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ evalContext := alerting.NewEvalContext(context.Background(),
Name: "This is an alarm", &alerting.Rule{
Message: "Some kind of message.", Name: "This is an alarm",
State: m.AlertStateOK, Message: "Some kind of message.",
}) State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext, caption := generateImageCaption(evalContext,
"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
@ -85,11 +88,12 @@ func TestTelegramNotifier(t *testing.T) {
}) })
Convey("Message should be trimmed if it's too long", func() { Convey("Message should be trimmed if it's too long", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ evalContext := alerting.NewEvalContext(context.Background(),
Name: "This is an alarm", &alerting.Rule{
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.", Name: "This is an alarm",
State: m.AlertStateOK, Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
}) State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext, caption := generateImageCaption(evalContext,
"http://grafa.url/foo", "http://grafa.url/foo",
@ -101,11 +105,12 @@ func TestTelegramNotifier(t *testing.T) {
}) })
Convey("Metrics should be skipped if they don't fit", func() { Convey("Metrics should be skipped if they don't fit", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{ evalContext := alerting.NewEvalContext(context.Background(),
Name: "This is an alarm", &alerting.Rule{
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ", Name: "This is an alarm",
State: m.AlertStateOK, Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
}) State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext, caption := generateImageCaption(evalContext,
"http://grafa.url/foo", "http://grafa.url/foo",

View File

@ -67,6 +67,12 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
} }
handler.log.Error("Failed to save state", "error", err) handler.log.Error("Failed to save state", "error", err)
} else {
// StateChanges is used for de duping alert notifications
// when two servers are raising. This makes sure that the server
// with the last state change always sends a notification.
evalContext.Rule.StateChanges = cmd.Result.StateChanges
} }
// save annotation // save annotation
@ -88,19 +94,6 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
} }
} }
if evalContext.Rule.State == m.AlertStateOK && evalContext.PrevAlertState != m.AlertStateOK {
for _, notifierId := range evalContext.Rule.Notifications {
cmd := &m.CleanNotificationJournalCommand{
AlertId: evalContext.Rule.Id,
NotifierId: notifierId,
OrgId: evalContext.Rule.OrgId,
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
handler.log.Error("Failed to clean up old notification records", "notifier", notifierId, "alert", evalContext.Rule.Id, "Error", err)
}
}
}
handler.notifier.SendIfNeeded(evalContext) handler.notifier.SendIfNeeded(evalContext)
return nil return nil
} }

View File

@ -23,6 +23,8 @@ type Rule struct {
State m.AlertStateType State m.AlertStateType
Conditions []Condition Conditions []Condition
Notifications []int64 Notifications []int64
StateChanges int64
} }
type ValidationError struct { type ValidationError struct {
@ -100,6 +102,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
model.State = ruleDef.State model.State = ruleDef.State
model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data")) model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data"))
model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting")) model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
model.StateChanges = ruleDef.StateChanges
for _, v := range ruleDef.Settings.Get("notifications").MustArray() { for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
jsonModel := simplejson.NewFromAny(v) jsonModel := simplejson.NewFromAny(v)

View File

@ -39,7 +39,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
return err return err
} }
return notifier.sendNotifications(createTestEvalContext(cmd), []Notifier{notifiers}) return notifier.sendNotifications(createTestEvalContext(cmd), notifierStateSlice{{notifier: notifiers}})
} }
func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext { func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext {

View File

@ -137,7 +137,7 @@ func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs ma
cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId}
err := bus.Dispatch(cmd) err := bus.Dispatch(cmd)
if err != nil { if err != nil {
fr.log.Error("failed to delete dashboard", "id", cmd.Id) fr.log.Error("failed to delete dashboard", "id", cmd.Id, "error", err)
} }
} }
} }

View File

@ -60,6 +60,10 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
return err return err
} }
if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil {
return err
}
return nil return nil
} }
@ -275,6 +279,8 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
} }
sess.ID(alert.Id).Update(&alert) sess.ID(alert.Id).Update(&alert)
cmd.Result = alert
return nil return nil
}) })
} }

View File

@ -3,6 +3,7 @@ package sqlstore
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -18,16 +19,23 @@ func init() {
bus.AddHandler("sql", DeleteAlertNotification) bus.AddHandler("sql", DeleteAlertNotification)
bus.AddHandler("sql", GetAlertNotificationsToSend) bus.AddHandler("sql", GetAlertNotificationsToSend)
bus.AddHandler("sql", GetAllAlertNotifications) bus.AddHandler("sql", GetAllAlertNotifications)
bus.AddHandlerCtx("sql", RecordNotificationJournal) bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
bus.AddHandlerCtx("sql", GetLatestNotification) bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
bus.AddHandlerCtx("sql", CleanNotificationJournal) bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
} }
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error { func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) error { return inTransaction(func(sess *DBSession) error {
sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?" sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?"
_, err := sess.Exec(sql, cmd.OrgId, cmd.Id) if _, err := sess.Exec(sql, cmd.OrgId, cmd.Id); err != nil {
return err return err
}
if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_notification_state.org_id = ? AND alert_notification_state.notifier_id = ?", cmd.OrgId, cmd.Id); err != nil {
return err
}
return nil
}) })
} }
@ -229,44 +237,123 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
}) })
} }
func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error { func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
return withDbSession(ctx, func(sess *DBSession) error { return inTransactionCtx(ctx, func(sess *DBSession) error {
journalEntry := &m.AlertNotificationJournal{ version := cmd.Version
OrgId: cmd.OrgId, var current m.AlertNotificationState
AlertId: cmd.AlertId, sess.ID(cmd.Id).Get(&current)
NotifierId: cmd.NotifierId,
SentAt: cmd.SentAt,
Success: cmd.Success,
}
_, err := sess.Insert(journalEntry) newVersion := cmd.Version + 1
return err
})
}
func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error { sql := `UPDATE alert_notification_state SET
return withDbSession(ctx, func(sess *DBSession) error { state = ?,
nj := []m.AlertNotificationJournal{} version = ?,
updated_at = ?
WHERE
id = ?`
err := sess.Desc("alert_notification_journal.sent_at"). _, err := sess.Exec(sql, m.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.Id)
Where("alert_notification_journal.org_id = ?", cmd.OrgId).
Where("alert_notification_journal.alert_id = ?", cmd.AlertId).
Where("alert_notification_journal.notifier_id = ?", cmd.NotifierId).
Find(&nj)
if err != nil { if err != nil {
return err return err
} }
cmd.Result = nj if current.Version != version {
sqlog.Error("notification state out of sync. the notification is marked as complete but has been modified between set as pending and completion.", "notifierId", current.NotifierId)
}
return nil return nil
}) })
} }
func CleanNotificationJournal(ctx context.Context, cmd *m.CleanNotificationJournalCommand) error { func SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToPendingCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error { return withDbSession(ctx, func(sess *DBSession) error {
sql := "DELETE FROM alert_notification_journal WHERE alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?" newVersion := cmd.Version + 1
_, err := sess.Exec(sql, cmd.OrgId, cmd.AlertId, cmd.NotifierId) sql := `UPDATE alert_notification_state SET
return err state = ?,
version = ?,
updated_at = ?,
alert_rule_state_updated_version = ?
WHERE
id = ? AND
(version = ? OR alert_rule_state_updated_version < ?)`
res, err := sess.Exec(sql,
m.AlertNotificationStatePending,
newVersion,
timeNow().Unix(),
cmd.AlertRuleStateUpdatedVersion,
cmd.Id,
cmd.Version,
cmd.AlertRuleStateUpdatedVersion)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return m.ErrAlertNotificationStateVersionConflict
}
cmd.ResultVersion = newVersion
return nil
}) })
} }
func GetOrCreateAlertNotificationState(ctx context.Context, cmd *m.GetOrCreateNotificationStateQuery) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
nj := &m.AlertNotificationState{}
exist, err := getAlertNotificationState(sess, cmd, nj)
// if exists, return it, otherwise create it with default values
if err != nil {
return err
}
if exist {
cmd.Result = nj
return nil
}
notificationState := &m.AlertNotificationState{
OrgId: cmd.OrgId,
AlertId: cmd.AlertId,
NotifierId: cmd.NotifierId,
State: m.AlertNotificationStateUnknown,
UpdatedAt: timeNow().Unix(),
}
if _, err := sess.Insert(notificationState); err != nil {
if dialect.IsUniqueConstraintViolation(err) {
exist, err = getAlertNotificationState(sess, cmd, nj)
if err != nil {
return err
}
if !exist {
return errors.New("Should not happen")
}
cmd.Result = nj
return nil
}
return err
}
cmd.Result = notificationState
return nil
})
}
func getAlertNotificationState(sess *DBSession, cmd *m.GetOrCreateNotificationStateQuery, nj *m.AlertNotificationState) (bool, error) {
return sess.
Where("alert_notification_state.org_id = ?", cmd.OrgId).
Where("alert_notification_state.alert_id = ?", cmd.AlertId).
Where("alert_notification_state.notifier_id = ?", cmd.NotifierId).
Get(nj)
}

View File

@ -6,7 +6,7 @@ import (
"time" "time"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
@ -14,58 +14,133 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Testing Alert notification sql access", t, func() { Convey("Testing Alert notification sql access", t, func() {
InitTestDB(t) InitTestDB(t)
Convey("Alert notification journal", func() { Convey("Alert notification state", func() {
var alertId int64 = 7 var alertID int64 = 7
var orgId int64 = 5 var orgID int64 = 5
var notifierId int64 = 10 var notifierID int64 = 10
oldTimeNow := timeNow
now := time.Date(2018, 9, 30, 0, 0, 0, 0, time.UTC)
timeNow = func() time.Time { return now }
Convey("Getting last journal should raise error if no one exists", func() { Convey("Get no existing state should create a new state", func() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId} query := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
GetLatestNotification(context.Background(), query) err := GetOrCreateAlertNotificationState(context.Background(), query)
So(len(query.Result), ShouldEqual, 0)
// recording an journal entry in another org to make sure org filter works as expected.
journalInOtherOrg := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: 10, Success: true, SentAt: 1}
err := RecordNotificationJournal(context.Background(), journalInOtherOrg)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
So(query.Result.State, ShouldEqual, "unknown")
So(query.Result.Version, ShouldEqual, 0)
So(query.Result.UpdatedAt, ShouldEqual, now.Unix())
Convey("should be able to record two journaling events", func() { Convey("Get existing state should not create a new state", func() {
createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1} query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err := GetOrCreateAlertNotificationState(context.Background(), query2)
err := RecordNotificationJournal(context.Background(), createCmd)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(query2.Result, ShouldNotBeNil)
So(query2.Result.Id, ShouldEqual, query.Result.Id)
So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
})
createCmd.SentAt += 1000 //increase epoch Convey("Update existing state to pending with correct version should update database", func() {
s := *query.Result
err = RecordNotificationJournal(context.Background(), createCmd) cmd := models.SetAlertNotificationStateToPendingCommand{
Id: s.Id,
Version: s.Version,
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
}
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(cmd.ResultVersion, ShouldEqual, 1)
Convey("get last journaling event", func() { query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err := GetLatestNotification(context.Background(), query) err = GetOrCreateAlertNotificationState(context.Background(), query2)
So(err, ShouldBeNil)
So(query2.Result.Version, ShouldEqual, 1)
So(query2.Result.State, ShouldEqual, models.AlertNotificationStatePending)
So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
Convey("Update existing state to completed should update database", func() {
s := *query.Result
setStateCmd := models.SetAlertNotificationStateToCompleteCommand{
Id: s.Id,
Version: cmd.ResultVersion,
}
err := SetAlertNotificationStateToCompleteCommand(context.Background(), &setStateCmd)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
last := query.Result[0]
So(last.SentAt, ShouldEqual, 1001)
Convey("be able to clear all journaling for an notifier", func() { query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId} err = GetOrCreateAlertNotificationState(context.Background(), query3)
err := CleanNotificationJournal(context.Background(), cmd) So(err, ShouldBeNil)
So(err, ShouldBeNil) So(query3.Result.Version, ShouldEqual, 2)
So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
})
Convey("querying for last journaling should return no journal entries", func() { Convey("Update existing state to completed should update database. regardless of version", func() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId} s := *query.Result
err := GetLatestNotification(context.Background(), query) unknownVersion := int64(1000)
So(err, ShouldBeNil) cmd := models.SetAlertNotificationStateToCompleteCommand{
So(len(query.Result), ShouldEqual, 0) Id: s.Id,
}) Version: unknownVersion,
}) }
err := SetAlertNotificationStateToCompleteCommand(context.Background(), &cmd)
So(err, ShouldBeNil)
query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err = GetOrCreateAlertNotificationState(context.Background(), query3)
So(err, ShouldBeNil)
So(query3.Result.Version, ShouldEqual, unknownVersion+1)
So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
}) })
}) })
Convey("Update existing state to pending with incorrect version should return version mismatch error", func() {
s := *query.Result
s.Version = 1000
cmd := models.SetAlertNotificationStateToPendingCommand{
Id: s.NotifierId,
Version: s.Version,
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
}
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldEqual, models.ErrAlertNotificationStateVersionConflict)
})
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,
AlertRuleStateUpdatedVersion: 1000,
}
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldBeNil)
So(cmd.ResultVersion, ShouldEqual, 1)
})
Convey("different version and same alert state change version should return error", func() {
s := *query.Result
s.Version = 1000
cmd := models.SetAlertNotificationStateToPendingCommand{
Id: s.Id,
Version: s.Version,
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
}
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldNotBeNil)
})
})
Reset(func() {
timeNow = oldTimeNow
}) })
}) })
Convey("Alert notifications should be empty", func() { Convey("Alert notifications should be empty", func() {
cmd := &m.GetAlertNotificationsQuery{ cmd := &models.GetAlertNotificationsQuery{
OrgId: 2, OrgId: 2,
Name: "email", Name: "email",
} }
@ -76,7 +151,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Cannot save alert notifier with send reminder = true", func() { Convey("Cannot save alert notifier with send reminder = true", func() {
cmd := &m.CreateAlertNotificationCommand{ cmd := &models.CreateAlertNotificationCommand{
Name: "ops", Name: "ops",
Type: "email", Type: "email",
OrgId: 1, OrgId: 1,
@ -86,7 +161,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("and missing frequency", func() { Convey("and missing frequency", func() {
err := CreateAlertNotificationCommand(cmd) err := CreateAlertNotificationCommand(cmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound) So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
}) })
Convey("invalid frequency", func() { Convey("invalid frequency", func() {
@ -98,7 +173,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Cannot update alert notifier with send reminder = false", func() { Convey("Cannot update alert notifier with send reminder = false", func() {
cmd := &m.CreateAlertNotificationCommand{ cmd := &models.CreateAlertNotificationCommand{
Name: "ops update", Name: "ops update",
Type: "email", Type: "email",
OrgId: 1, OrgId: 1,
@ -109,14 +184,14 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
err := CreateAlertNotificationCommand(cmd) err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil) So(err, ShouldBeNil)
updateCmd := &m.UpdateAlertNotificationCommand{ updateCmd := &models.UpdateAlertNotificationCommand{
Id: cmd.Result.Id, Id: cmd.Result.Id,
SendReminder: true, SendReminder: true,
} }
Convey("and missing frequency", func() { Convey("and missing frequency", func() {
err := UpdateAlertNotification(updateCmd) err := UpdateAlertNotification(updateCmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound) So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
}) })
Convey("invalid frequency", func() { Convey("invalid frequency", func() {
@ -129,7 +204,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Can save Alert Notification", func() { Convey("Can save Alert Notification", func() {
cmd := &m.CreateAlertNotificationCommand{ cmd := &models.CreateAlertNotificationCommand{
Name: "ops", Name: "ops",
Type: "email", Type: "email",
OrgId: 1, OrgId: 1,
@ -151,7 +226,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Can update alert notification", func() { Convey("Can update alert notification", func() {
newCmd := &m.UpdateAlertNotificationCommand{ newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName", Name: "NewName",
Type: "webhook", Type: "webhook",
OrgId: cmd.Result.OrgId, OrgId: cmd.Result.OrgId,
@ -167,7 +242,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Can update alert notification to disable sending of reminders", func() { Convey("Can update alert notification to disable sending of reminders", func() {
newCmd := &m.UpdateAlertNotificationCommand{ newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName", Name: "NewName",
Type: "webhook", Type: "webhook",
OrgId: cmd.Result.OrgId, OrgId: cmd.Result.OrgId,
@ -182,12 +257,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("Can search using an array of ids", func() { Convey("Can search using an array of ids", func() {
cmd1 := m.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} cmd1 := models.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd2 := m.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} cmd2 := models.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd3 := m.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} cmd3 := models.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} cmd4 := models.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
otherOrg := m.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()} otherOrg := models.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
So(CreateAlertNotificationCommand(&cmd1), ShouldBeNil) So(CreateAlertNotificationCommand(&cmd1), ShouldBeNil)
So(CreateAlertNotificationCommand(&cmd2), ShouldBeNil) So(CreateAlertNotificationCommand(&cmd2), ShouldBeNil)
@ -196,7 +271,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil) So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
Convey("search", func() { Convey("search", func() {
query := &m.GetAlertNotificationsToSendQuery{ query := &models.GetAlertNotificationsToSendQuery{
Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231}, Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
OrgId: 1, OrgId: 1,
} }
@ -207,7 +282,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
}) })
Convey("all", func() { Convey("all", func() {
query := &m.GetAllAlertNotificationsQuery{ query := &models.GetAllAlertNotificationsQuery{
OrgId: 1, OrgId: 1,
} }

View File

@ -107,4 +107,27 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("create notification_journal table v1", NewAddTableMigration(notification_journal)) mg.AddMigration("create notification_journal table v1", NewAddTableMigration(notification_journal))
mg.AddMigration("add index notification_journal org_id & alert_id & notifier_id", NewAddIndexMigration(notification_journal, notification_journal.Indices[0])) mg.AddMigration("add index notification_journal org_id & alert_id & notifier_id", NewAddIndexMigration(notification_journal, notification_journal.Indices[0]))
mg.AddMigration("drop alert_notification_journal", NewDropTableMigration("alert_notification_journal"))
alert_notification_state := Table{
Name: "alert_notification_state",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: DB_BigInt, Nullable: false},
{Name: "alert_id", Type: DB_BigInt, Nullable: false},
{Name: "notifier_id", Type: DB_BigInt, Nullable: false},
{Name: "state", Type: DB_NVarchar, Length: 50, Nullable: false},
{Name: "version", Type: DB_BigInt, Nullable: false},
{Name: "updated_at", Type: DB_BigInt, Nullable: false},
{Name: "alert_rule_state_updated_version", Type: DB_BigInt, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id", "alert_id", "notifier_id"}, Type: UniqueIndex},
},
}
mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state))
mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id",
NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0]))
} }

View File

@ -44,6 +44,8 @@ type Dialect interface {
CleanDB() error CleanDB() error
NoOpSql() string NoOpSql() string
IsUniqueConstraintViolation(err error) bool
} }
func NewDialect(engine *xorm.Engine) Dialect { func NewDialect(engine *xorm.Engine) Dialect {

View File

@ -5,6 +5,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/VividCortex/mysqlerr"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm" "github.com/go-xorm/xorm"
) )
@ -125,3 +127,13 @@ func (db *Mysql) CleanDB() error {
return nil return nil
} }
func (db *Mysql) IsUniqueConstraintViolation(err error) bool {
if driverErr, ok := err.(*mysql.MySQLError); ok {
if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
return true
}
}
return false
}

View File

@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/go-xorm/xorm" "github.com/go-xorm/xorm"
"github.com/lib/pq"
) )
type Postgres struct { type Postgres struct {
@ -136,3 +137,13 @@ func (db *Postgres) CleanDB() error {
return nil return nil
} }
func (db *Postgres) IsUniqueConstraintViolation(err error) bool {
if driverErr, ok := err.(*pq.Error); ok {
if driverErr.Code == "23505" {
return true
}
}
return false
}

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/go-xorm/xorm" "github.com/go-xorm/xorm"
sqlite3 "github.com/mattn/go-sqlite3"
) )
type Sqlite3 struct { type Sqlite3 struct {
@ -82,3 +83,13 @@ func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
func (db *Sqlite3) CleanDB() error { func (db *Sqlite3) CleanDB() error {
return nil return nil
} }
func (db *Sqlite3) IsUniqueConstraintViolation(err error) bool {
if driverErr, ok := err.(sqlite3.Error); ok {
if driverErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return true
}
}
return false
}

View File

@ -25,7 +25,7 @@ func TestClient(t *testing.T) {
JsonData: simplejson.NewFromAny(make(map[string]interface{})), JsonData: simplejson.NewFromAny(make(map[string]interface{})),
} }
_, err := NewClient(nil, ds, nil) _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil) So(err, ShouldNotBeNil)
}) })
@ -36,7 +36,7 @@ func TestClient(t *testing.T) {
}), }),
} }
_, err := NewClient(nil, ds, nil) _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil) So(err, ShouldNotBeNil)
}) })
@ -48,7 +48,7 @@ func TestClient(t *testing.T) {
}), }),
} }
_, err := NewClient(nil, ds, nil) _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil) So(err, ShouldNotBeNil)
}) })
@ -60,7 +60,7 @@ func TestClient(t *testing.T) {
}), }),
} }
c, err := NewClient(nil, ds, nil) c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 2) So(c.GetVersion(), ShouldEqual, 2)
}) })
@ -73,7 +73,7 @@ func TestClient(t *testing.T) {
}), }),
} }
c, err := NewClient(nil, ds, nil) c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 5) So(c.GetVersion(), ShouldEqual, 5)
}) })
@ -86,7 +86,7 @@ func TestClient(t *testing.T) {
}), }),
} }
c, err := NewClient(nil, ds, nil) c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 56) So(c.GetVersion(), ShouldEqual, 56)
}) })

View File

@ -66,10 +66,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
} }
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeFrom":
return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
case "__timeTo":
return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeGroup": case "__timeGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name) return "", fmt.Errorf("macro %v needs time column and interval", name)
@ -96,10 +92,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name) return "", fmt.Errorf("missing time column argument for macro %v", name)
} }
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochFrom":
return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
case "__unixEpochTo":
return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup": case "__unixEpochGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

View File

@ -111,20 +111,6 @@ func TestMacroEngine(t *testing.T) {
So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) So(fillInterval, ShouldEqual, 5*time.Minute.Seconds())
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
@ -132,20 +118,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
Convey("interpolate __unixEpochGroup function", func() { Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@ -171,40 +143,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@ -219,40 +163,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
}) })
} }

View File

@ -1,6 +1,7 @@
package mssql package mssql
import ( import (
"context"
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
@ -128,7 +129,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
@ -218,7 +219,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -265,7 +266,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -327,7 +328,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -352,7 +353,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -441,7 +442,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -463,7 +464,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -485,7 +486,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -507,7 +508,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -529,7 +530,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -551,7 +552,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -573,7 +574,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -595,7 +596,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -617,7 +618,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -640,7 +641,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -663,7 +664,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -675,6 +676,30 @@ func TestMSSQL(t *testing.T) {
So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo")
}) })
Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
"format": "time_series",
}),
RefId: "A",
},
},
}
resp, err := endpoint.Query(nil, nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
})
Convey("Given a stored procedure that takes @from and @to in epoch time", func() { Convey("Given a stored procedure that takes @from and @to in epoch time", func() {
sql := ` sql := `
IF object_id('sp_test_epoch') IS NOT NULL IF object_id('sp_test_epoch') IS NOT NULL
@ -719,9 +744,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil) So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() { Convey("When doing a metric query using stored procedure should return correct result", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{ query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{ Queries: []*tsdb.Query{
{ {
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{ Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE "rawSql": `DECLARE
@from int = $__unixEpochFrom(), @from int = $__unixEpochFrom(),
@ -739,7 +766,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -796,9 +823,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil) So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() { Convey("When doing a metric query using stored procedure should return correct result", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{ query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{ Queries: []*tsdb.Query{
{ {
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{ Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE "rawSql": `DECLARE
@from int = $__unixEpochFrom(), @from int = $__unixEpochFrom(),
@ -816,7 +845,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -892,7 +921,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"] queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -915,7 +944,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"] queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -941,7 +970,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -971,7 +1000,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -1001,7 +1030,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -1031,7 +1060,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -1059,7 +1088,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -1087,7 +1116,7 @@ func TestMSSQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)

View File

@ -61,10 +61,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
} }
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeFrom":
return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
case "__timeTo":
return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeGroup": case "__timeGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name) return "", fmt.Errorf("macro %v needs time column and interval", name)
@ -91,10 +87,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name) return "", fmt.Errorf("missing time column argument for macro %v", name)
} }
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochFrom":
return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
case "__unixEpochTo":
return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup": case "__unixEpochGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

View File

@ -63,20 +63,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
@ -84,20 +70,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
Convey("interpolate __unixEpochGroup function", func() { Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@ -123,40 +95,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@ -171,40 +115,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
}) })
} }

View File

@ -1,6 +1,7 @@
package mysql package mysql
import ( import (
"context"
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
@ -129,7 +130,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -217,7 +218,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -264,7 +265,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -313,7 +314,7 @@ func TestMySQL(t *testing.T) {
query := &tsdb.TsdbQuery{ query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{ Queries: []*tsdb.Query{
{ {
DataSource: &models.DataSource{}, DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{ Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1",
"format": "time_series", "format": "time_series",
@ -327,7 +328,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -352,7 +353,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -378,7 +379,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -473,7 +474,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -495,7 +496,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -517,7 +518,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -539,7 +540,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -561,7 +562,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -583,7 +584,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -605,7 +606,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -627,7 +628,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -649,7 +650,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -671,7 +672,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -693,7 +694,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -716,7 +717,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -741,7 +742,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -752,6 +753,30 @@ func TestMySQL(t *testing.T) {
}) })
}) })
Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
"format": "time_series",
}),
RefId: "A",
},
},
}
resp, err := endpoint.Query(nil, nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
})
Convey("Given a table with event data", func() { Convey("Given a table with event data", func() {
type event struct { type event struct {
TimeSec int64 TimeSec int64
@ -802,7 +827,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"] queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -825,7 +850,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"] queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -851,7 +876,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -881,7 +906,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -911,7 +936,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -941,7 +966,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -969,7 +994,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -997,7 +1022,7 @@ func TestMySQL(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)

View File

@ -87,10 +87,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
} }
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeFrom":
return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
case "__timeTo":
return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeGroup": case "__timeGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
@ -122,10 +118,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
return "", fmt.Errorf("missing time column argument for macro %v", name) return "", fmt.Errorf("missing time column argument for macro %v", name)
} }
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochFrom":
return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
case "__unixEpochTo":
return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup": case "__unixEpochGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

View File

@ -44,13 +44,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeGroup function pre 5.3 compatibility", func() { Convey("interpolate __timeGroup function pre 5.3 compatibility", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value") sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value")
@ -102,13 +95,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)") So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)")
}) })
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
@ -116,20 +102,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
Convey("interpolate __unixEpochGroup function", func() { Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@ -155,40 +127,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@ -203,40 +147,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFrom function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
}) })
} }

View File

@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"context"
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
@ -117,7 +118,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -197,7 +198,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -254,7 +255,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -279,7 +280,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -333,7 +334,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -360,7 +361,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -450,7 +451,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -472,7 +473,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -494,7 +495,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -516,7 +517,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -538,7 +539,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -560,7 +561,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -582,7 +583,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -604,7 +605,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -626,7 +627,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -649,7 +650,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -674,7 +675,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -683,6 +684,30 @@ func TestPostgres(t *testing.T) {
So(queryResult.Series[0].Name, ShouldEqual, "valueOne") So(queryResult.Series[0].Name, ShouldEqual, "valueOne")
So(queryResult.Series[1].Name, ShouldEqual, "valueTwo") So(queryResult.Series[1].Name, ShouldEqual, "valueTwo")
}) })
Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
"format": "time_series",
}),
RefId: "A",
},
},
}
resp, err := endpoint.Query(nil, nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
})
}) })
Convey("Given a table with event data", func() { Convey("Given a table with event data", func() {
@ -735,7 +760,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"] queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -758,7 +783,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"] queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3) So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@ -784,7 +809,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -814,7 +839,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -844,7 +869,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -874,7 +899,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -902,7 +927,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)
@ -930,7 +955,7 @@ func TestPostgres(t *testing.T) {
}, },
} }
resp, err := endpoint.Query(nil, nil, query) resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil) So(err, ShouldBeNil)
queryResult := resp.Results["A"] queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil) So(queryResult.Error, ShouldBeNil)

View File

@ -184,6 +184,10 @@ var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string,
sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1) sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1)
sql = strings.Replace(sql, "$__interval", interval.Text, -1) sql = strings.Replace(sql, "$__interval", interval.Text, -1)
sql = strings.Replace(sql, "$__timeFrom()", fmt.Sprintf("'%s'", timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), -1)
sql = strings.Replace(sql, "$__timeTo()", fmt.Sprintf("'%s'", timeRange.GetToAsTimeUTC().Format(time.RFC3339)), -1)
sql = strings.Replace(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.GetFromAsSecondsEpoch()), -1)
sql = strings.Replace(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.GetToAsSecondsEpoch()), -1)
return sql, nil return sql, nil
} }

View File

@ -1,6 +1,7 @@
package tsdb package tsdb
import ( import (
"fmt"
"testing" "testing"
"time" "time"
@ -43,6 +44,34 @@ func TestSqlEngine(t *testing.T) {
So(sql, ShouldEqual, "select 60000 ") So(sql, ShouldEqual, "select 60000 ")
}) })
Convey("interpolate __timeFrom function", func() {
sql, err := Interpolate(query, timeRange, "select $__timeFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
})
Convey("interpolate __timeTo function", func() {
sql, err := Interpolate(query, timeRange, "select $__timeTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
})
Convey("interpolate __unixEpochFrom function", func() {
sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
})
Convey("interpolate __unixEpochTo function", func() {
sql, err := Interpolate(query, timeRange, "select $__unixEpochTo()")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
})
}) })
Convey("Given row values with time.Time as time columns", func() { Convey("Given row values with time.Time as time columns", func() {

View File

@ -95,27 +95,20 @@ func init() {
Id: "random_walk", Id: "random_walk",
Name: "Random Walk", Name: "Random Walk",
Handler: func(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult { Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch() return getRandomWalk(query, context)
to := tsdbQuery.TimeRange.GetToAsMsEpoch() },
})
series := newSeriesForQuery(query) registerScenario(&Scenario{
Id: "slow_query",
points := make(tsdb.TimeSeriesPoints, 0) Name: "Slow Query",
walker := rand.Float64() * 100 StringInput: "5s",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
for i := int64(0); i < 10000 && timeWalkerMs < to; i++ { stringInput := query.Model.Get("stringInput").MustString()
points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs))) parsedInterval, _ := time.ParseDuration(stringInput)
time.Sleep(parsedInterval)
walker += rand.Float64() - 0.5 return getRandomWalk(query, context)
timeWalkerMs += query.IntervalMs
}
series.Points = points
queryRes := tsdb.NewQueryResult()
queryRes.Series = append(queryRes.Series, series)
return queryRes
}, },
}) })
@ -221,6 +214,57 @@ func init() {
return queryRes return queryRes
}, },
}) })
registerScenario(&Scenario{
Id: "table_static",
Name: "Table Static",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
timeWalkerMs := context.TimeRange.GetFromAsMsEpoch()
to := context.TimeRange.GetToAsMsEpoch()
table := tsdb.Table{
Columns: []tsdb.TableColumn{
{Text: "Time"},
{Text: "Message"},
{Text: "Description"},
{Text: "Value"},
},
Rows: []tsdb.RowValues{},
}
for i := int64(0); i < 10 && timeWalkerMs < to; i++ {
table.Rows = append(table.Rows, tsdb.RowValues{float64(timeWalkerMs), "This is a message", "Description", 23.1})
timeWalkerMs += query.IntervalMs
}
queryRes := tsdb.NewQueryResult()
queryRes.Tables = append(queryRes.Tables, &table)
return queryRes
},
})
}
func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
to := tsdbQuery.TimeRange.GetToAsMsEpoch()
series := newSeriesForQuery(query)
points := make(tsdb.TimeSeriesPoints, 0)
walker := rand.Float64() * 100
for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
walker += rand.Float64() - 0.5
timeWalkerMs += query.IntervalMs
}
series.Points = points
queryRes := tsdb.NewQueryResult()
queryRes.Series = append(queryRes.Series, series)
return queryRes
} }
func registerScenario(scenario *Scenario) { func registerScenario(scenario *Scenario) {

View File

@ -0,0 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import OrgActionBar, { Props } from './OrgActionBar';
const setup = (propOverrides?: object) => {
const props: Props = {
searchQuery: '',
setSearchQuery: jest.fn(),
target: '_blank',
linkButton: { href: 'some/url', title: 'test' },
};
Object.assign(props, propOverrides);
return shallow(<OrgActionBar {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,44 @@
import React, { PureComponent } from 'react';
import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
export interface Props {
searchQuery: string;
layoutMode?: LayoutMode;
onSetLayoutMode?: (mode: LayoutMode) => {};
setSearchQuery: (value: string) => {};
linkButton: { href: string; title: string };
target?: string;
}
export default class OrgActionBar extends PureComponent<Props> {
render() {
const { searchQuery, layoutMode, onSetLayoutMode, linkButton, setSearchQuery, target } = this.props;
const linkProps = { href: linkButton.href, target: undefined };
if (target) {
linkProps.target = target;
}
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={event => setSearchQuery(event.target.value)}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => onSetLayoutMode(mode)} />
</div>
<div className="page-action-bar__spacer" />
<a className="btn btn-success" {...linkProps}>
{linkButton.title}
</a>
</div>
);
}
}

View File

@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
/> />
</label> </label>
<LayoutSelector <LayoutSelector
mode="grid"
onLayoutModeChanged={[Function]} onLayoutModeChanged={[Function]}
/> />
</div> </div>
@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
/> />
<a <a
className="btn btn-success" className="btn btn-success"
href="https://grafana.com/plugins?utm_source=grafana_plugin_list" href="some/url"
target="_blank" target="_blank"
> >
Find more plugins on Grafana.com test
</a> </a>
</div> </div>
`; `;

View File

@ -10,6 +10,7 @@ import colors from 'app/core/utils/colors';
import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
export class GrafanaCtrl { export class GrafanaCtrl {
/** @ngInject */ /** @ngInject */
@ -22,11 +23,13 @@ export class GrafanaCtrl {
contextSrv, contextSrv,
bridgeSrv, bridgeSrv,
backendSrv: BackendSrv, backendSrv: BackendSrv,
datasourceSrv: DatasourceSrv datasourceSrv: DatasourceSrv,
angularLoader: AngularLoader
) { ) {
// sets singleston instances for angular services so react components can access them // sets singleston instances for angular services so react components can access them
configureStore(); setAngularLoader(angularLoader);
setBackendSrv(backendSrv); setBackendSrv(backendSrv);
configureStore();
$scope.init = () => { $scope.init = () => {
$scope.contextSrv = contextSrv; $scope.contextSrv = contextSrv;

View File

@ -0,0 +1,42 @@
import angular from 'angular';
import coreModule from 'app/core/core_module';
import _ from 'lodash';
export interface AngularComponent {
destroy();
}
export class AngularLoader {
/** @ngInject */
constructor(private $compile, private $rootScope) {}
load(elem, scopeProps, template): AngularComponent {
const scope = this.$rootScope.$new();
_.assign(scope, scopeProps);
const compiledElem = this.$compile(template)(scope);
const rootNode = angular.element(elem);
rootNode.append(compiledElem);
return {
destroy: () => {
scope.$destroy();
compiledElem.remove();
},
};
}
}
coreModule.service('angularLoader', AngularLoader);
let angularLoaderInstance: AngularLoader;
export function setAngularLoader(pl: AngularLoader) {
angularLoaderInstance = pl;
}
// away to access it from react
export function getAngularLoader(): AngularLoader {
return angularLoaderInstance;
}

View File

@ -4,7 +4,7 @@ import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { renderUrl } from 'app/core/utils/url'; import { getExploreUrl } from 'app/core/utils/explore';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind'; import 'mousetrap-global-bind';
@ -15,7 +15,14 @@ export class KeybindingSrv {
timepickerOpen = false; timepickerOpen = false;
/** @ngInject */ /** @ngInject */
constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv, private contextSrv) { constructor(
private $rootScope,
private $location,
private $timeout,
private datasourceSrv,
private timeSrv,
private contextSrv
) {
// clear out all shortcuts on route change // clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => { $rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset(); Mousetrap.reset();
@ -194,14 +201,9 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) { if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId); const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource); const datasource = await this.datasourceSrv.get(panel.datasource);
if (datasource && datasource.supportsExplore) { const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
const range = this.timeSrv.timeRangeForUrl(); if (url) {
const state = { this.$timeout(() => this.$location.url(url));
...datasource.getExploreState(panel),
range,
};
const exploreState = JSON.stringify(state);
this.$location.url(renderUrl('/explore', { state: exploreState }));
} }
} }
}); });

View File

@ -1,6 +1,5 @@
import { serializeStateToUrlParam, parseUrlState } from './Wrapper'; import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore';
import { DEFAULT_RANGE } from './TimePicker'; import { ExploreState } from 'app/types/explore';
import { ExploreState } from './Explore';
const DEFAULT_EXPLORE_STATE: ExploreState = { const DEFAULT_EXPLORE_STATE: ExploreState = {
datasource: null, datasource: null,
@ -8,6 +7,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
datasourceLoading: null, datasourceLoading: null,
datasourceMissing: false, datasourceMissing: false,
datasourceName: '', datasourceName: '',
exploreDatasources: [],
graphResult: null, graphResult: null,
history: [], history: [],
latency: 0, latency: 0,
@ -27,7 +27,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
tableResult: null, tableResult: null,
}; };
describe('Wrapper state functions', () => { describe('state functions', () => {
describe('parseUrlState', () => { describe('parseUrlState', () => {
it('returns default state on empty string', () => { it('returns default state on empty string', () => {
expect(parseUrlState('')).toMatchObject({ expect(parseUrlState('')).toMatchObject({
@ -57,7 +57,7 @@ describe('Wrapper state functions', () => {
}; };
expect(serializeStateToUrlParam(state)).toBe( expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' + '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}' '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
); );
}); });
}); });

View File

@ -0,0 +1,78 @@
import { renderUrl } from 'app/core/utils/url';
import { ExploreState, ExploreUrlState } from 'app/types/explore';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
/**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
*
* @param panel Origin panel of the jump to Explore
* @param panelTargets The origin panel's query targets
* @param panelDatasource The origin panel's datasource
* @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
* @param timeSrv Time service to get the current dashboard range from
*/
export async function getExploreUrl(
panel: any,
panelTargets: any[],
panelDatasource: any,
datasourceSrv: any,
timeSrv: any
) {
let exploreDatasource = panelDatasource;
let exploreTargets = panelTargets;
let url;
// Mixed datasources need to choose only one datasource
if (panelDatasource.meta.id === 'mixed' && panelTargets) {
// Find first explore datasource among targets
let mixedExploreDatasource;
for (const t of panel.targets) {
const datasource = await datasourceSrv.get(t.datasource);
if (datasource && datasource.meta.explore) {
mixedExploreDatasource = datasource;
break;
}
}
// Add all its targets
if (mixedExploreDatasource) {
exploreDatasource = mixedExploreDatasource;
exploreTargets = panelTargets.filter(t => t.datasource === mixedExploreDatasource.name);
}
}
if (exploreDatasource && exploreDatasource.meta.explore) {
const range = timeSrv.timeRangeForUrl();
const state = {
...exploreDatasource.getExploreState(exploreTargets),
range,
};
const exploreState = JSON.stringify(state);
url = renderUrl('/explore', { state: exploreState });
}
return url;
}
export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) {
try {
return JSON.parse(decodeURI(initial));
} catch (e) {
console.error(e);
}
}
return { datasource: null, queries: [], range: DEFAULT_RANGE };
}
export function serializeStateToUrlParam(state: ExploreState): string {
const urlState: ExploreUrlState = {
datasource: state.datasourceName,
queries: state.queries.map(q => ({ query: q.query })),
range: state.range,
};
return JSON.stringify(urlState);
}

View File

@ -1,10 +1,5 @@
import config from 'app/core/config'; import config from 'app/core/config';
// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
const SLASH = '<SLASH>';
export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
export const stripBaseFromUrl = url => { export const stripBaseFromUrl = url => {
const appSubUrl = config.appSubUrl; const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0; const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;

View File

@ -58,7 +58,7 @@ export function updateDashboardPermission(
continue; continue;
} }
const updated = toUpdateItem(itemToUpdate); const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion // if this is the item we want to update, update it's permisssion
if (itemToUpdate === item) { if (itemToUpdate === item) {

View File

@ -1,23 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
const setup = (propOverrides?: object) => {
const props: Props = {
layoutMode: LayoutModes.Grid,
searchQuery: '',
setDataSourcesLayoutMode: jest.fn(),
setDataSourcesSearchQuery: jest.fn(),
};
return shallow(<DataSourcesActionBar {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,62 +0,0 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors';
export interface Props {
searchQuery: string;
layoutMode: LayoutMode;
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
}
export class DataSourcesActionBar extends PureComponent<Props> {
onSearchQueryChange = event => {
this.props.setDataSourcesSearchQuery(event.target.value);
};
render() {
const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props;
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={this.onSearchQueryChange}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<LayoutSelector
mode={layoutMode}
onLayoutModeChanged={(mode: LayoutMode) => setDataSourcesLayoutMode(mode)}
/>
</div>
<div className="page-action-bar__spacer" />
<a className="page-header__cta btn btn-success" href="datasources/new">
<i className="fa fa-plus" />
Add data source
</a>
</div>
);
}
}
function mapStateToProps(state) {
return {
searchQuery: getDataSourcesSearchQuery(state.dataSources),
layoutMode: getDataSourcesLayoutMode(state.dataSources),
};
}
const mapDispatchToProps = {
setDataSourcesLayoutMode,
setDataSourcesSearchQuery,
};
export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar);

View File

@ -12,6 +12,9 @@ const setup = (propOverrides?: object) => {
loadDataSources: jest.fn(), loadDataSources: jest.fn(),
navModel: {} as NavModel, navModel: {} as NavModel,
dataSourcesCount: 0, dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
setDataSourcesLayoutMode: jest.fn(),
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);

View File

@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import PageHeader from '../../core/components/PageHeader/PageHeader'; import PageHeader from '../../core/components/PageHeader/PageHeader';
import DataSourcesActionBar from './DataSourcesActionBar'; import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
import DataSourcesList from './DataSourcesList'; import DataSourcesList from './DataSourcesList';
import { loadDataSources } from './state/actions';
import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
import { getNavModel } from '../../core/selectors/navModel';
import { DataSource, NavModel } from 'app/types'; import { DataSource, NavModel } from 'app/types';
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA'; import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
import { getNavModel } from '../../core/selectors/navModel';
import {
getDataSources,
getDataSourcesCount,
getDataSourcesLayoutMode,
getDataSourcesSearchQuery,
} from './state/selectors';
export interface Props { export interface Props {
navModel: NavModel; navModel: NavModel;
dataSources: DataSource[]; dataSources: DataSource[];
dataSourcesCount: number; dataSourcesCount: number;
layoutMode: LayoutMode; layoutMode: LayoutMode;
searchQuery: string;
loadDataSources: typeof loadDataSources; loadDataSources: typeof loadDataSources;
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
} }
const emptyListModel = { const emptyListModel = {
@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
} }
render() { render() {
const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props; const {
dataSources,
dataSourcesCount,
navModel,
layoutMode,
searchQuery,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
} = this.props;
const linkButton = {
href: 'datasources/new',
title: 'Add data source',
};
return ( return (
<div> <div>
@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
<EmptyListCTA model={emptyListModel} /> <EmptyListCTA model={emptyListModel} />
) : ( ) : (
[ [
<DataSourcesActionBar key="action-bar" />, <OrgActionBar
layoutMode={layoutMode}
searchQuery={searchQuery}
onSetLayoutMode={mode => setDataSourcesLayoutMode(mode)}
setSearchQuery={query => setDataSourcesSearchQuery(query)}
linkButton={linkButton}
key="action-bar"
/>,
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />, <DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
] ]
)} )}
@ -66,11 +94,14 @@ function mapStateToProps(state) {
dataSources: getDataSources(state.dataSources), dataSources: getDataSources(state.dataSources),
layoutMode: getDataSourcesLayoutMode(state.dataSources), layoutMode: getDataSourcesLayoutMode(state.dataSources),
dataSourcesCount: getDataSourcesCount(state.dataSources), dataSourcesCount: getDataSourcesCount(state.dataSources),
searchQuery: getDataSourcesSearchQuery(state.dataSources),
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadDataSources, loadDataSources,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
}; };
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage)); export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));

View File

@ -1,42 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon"
>
<input
className="gf-form-input width-20"
onChange={[Function]}
placeholder="Filter by name or type"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<LayoutSelector
mode="grid"
onLayoutModeChanged={[Function]}
/>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="page-header__cta btn btn-success"
href="datasources/new"
>
<i
className="fa fa-plus"
/>
Add data source
</a>
</div>
`;

View File

@ -8,8 +8,18 @@ exports[`Render should render action bar and datasources 1`] = `
<div <div
className="page-container page-body" className="page-container page-body"
> >
<Connect(DataSourcesActionBar) <OrgActionBar
key="action-bar" key="action-bar"
layoutMode="grid"
linkButton={
Object {
"href": "datasources/new",
"title": "Add data source",
}
}
onSetLayoutMode={[Function]}
searchQuery=""
setSearchQuery={[Function]}
/> />
<DataSourcesList <DataSourcesList
dataSources={ dataSources={

View File

@ -2,19 +2,20 @@ import React from 'react';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import Select from 'react-select'; import Select from 'react-select';
import { Query, Range, ExploreUrlState } from 'app/types/explore'; import { ExploreState, ExploreUrlState, Query } from 'app/types/explore';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import store from 'app/core/store'; import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
import { parse as parseDate } from 'app/core/utils/datemath'; import { parse as parseDate } from 'app/core/utils/datemath';
import { DEFAULT_RANGE } from 'app/core/utils/explore';
import ElapsedTime from './ElapsedTime'; import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Logs from './Logs'; import Logs from './Logs';
import Table from './Table'; import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker'; import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100; const MAX_HISTORY_ITEMS = 100;
@ -58,64 +59,52 @@ interface ExploreProps {
urlState: ExploreUrlState; urlState: ExploreUrlState;
} }
export interface ExploreState {
datasource: any;
datasourceError: any;
datasourceLoading: boolean | null;
datasourceMissing: boolean;
datasourceName?: string;
graphResult: any;
history: any[];
latency: number;
loading: any;
logsResult: any;
queries: Query[];
queryErrors: any[];
queryHints: any[];
range: Range;
requestOptions: any;
showingGraph: boolean;
showingLogs: boolean;
showingTable: boolean;
supportsGraph: boolean | null;
supportsLogs: boolean | null;
supportsTable: boolean | null;
tableResult: any;
}
export class Explore extends React.PureComponent<ExploreProps, ExploreState> { export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
el: any; el: any;
/**
* Current query expressions of the rows including their modifications, used for running queries.
* Not kept in component state to prevent edit-render roundtrips.
*/
queryExpressions: string[];
constructor(props) { constructor(props) {
super(props); super(props);
// Split state overrides everything
const splitState: ExploreState = props.splitState; const splitState: ExploreState = props.splitState;
const { datasource, queries, range } = props.urlState; let initialQueries: Query[];
this.state = { if (splitState) {
datasource: null, // Split state overrides everything
datasourceError: null, this.state = splitState;
datasourceLoading: null, initialQueries = splitState.queries;
datasourceMissing: false, } else {
datasourceName: datasource, const { datasource, queries, range } = props.urlState as ExploreUrlState;
graphResult: null, initialQueries = ensureQueries(queries);
history: [], this.state = {
latency: 0, datasource: null,
loading: false, datasourceError: null,
logsResult: null, datasourceLoading: null,
queries: ensureQueries(queries), datasourceMissing: false,
queryErrors: [], datasourceName: datasource,
queryHints: [], exploreDatasources: [],
range: range || { ...DEFAULT_RANGE }, graphResult: null,
requestOptions: null, history: [],
showingGraph: true, latency: 0,
showingLogs: true, loading: false,
showingTable: true, logsResult: null,
supportsGraph: null, queries: initialQueries,
supportsLogs: null, queryErrors: [],
supportsTable: null, queryHints: [],
tableResult: null, range: range || { ...DEFAULT_RANGE },
...splitState, requestOptions: null,
}; showingGraph: true,
showingLogs: true,
showingTable: true,
supportsGraph: null,
supportsLogs: null,
supportsTable: null,
tableResult: null,
};
}
this.queryExpressions = initialQueries.map(q => q.query);
} }
async componentDidMount() { async componentDidMount() {
@ -125,8 +114,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
throw new Error('No datasource service passed as props.'); throw new Error('No datasource service passed as props.');
} }
const datasources = datasourceSrv.getExploreSources(); const datasources = datasourceSrv.getExploreSources();
const exploreDatasources = datasources.map(ds => ({
value: ds.name,
label: ds.name,
}));
if (datasources.length > 0) { if (datasources.length > 0) {
this.setState({ datasourceLoading: true }); this.setState({ datasourceLoading: true, exploreDatasources });
// Priority: datasource in url, default datasource, first explore datasource // Priority: datasource in url, default datasource, first explore datasource
let datasource; let datasource;
if (datasourceName) { if (datasourceName) {
@ -170,9 +164,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
// Keep queries but reset edit state // Keep queries but reset edit state
const nextQueries = this.state.queries.map(q => ({ const nextQueries = this.state.queries.map((q, i) => ({
...q, ...q,
edited: false, key: generateQueryKey(i),
query: this.queryExpressions[i],
})); }));
this.setState( this.setState(
@ -201,6 +196,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onAddQueryRow = index => { onAddQueryRow = index => {
const { queries } = this.state; const { queries } = this.state;
this.queryExpressions[index + 1] = '';
const nextQueries = [ const nextQueries = [
...queries.slice(0, index + 1), ...queries.slice(0, index + 1),
{ query: '', key: generateQueryKey() }, { query: '', key: generateQueryKey() },
@ -227,29 +223,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onChangeQuery = (value: string, index: number, override?: boolean) => { onChangeQuery = (value: string, index: number, override?: boolean) => {
const { queries } = this.state; // Keep current value in local cache
let { queryErrors, queryHints } = this.state; this.queryExpressions[index] = value;
const prevQuery = queries[index];
const edited = override ? false : prevQuery.query !== value; // Replace query row on override
const nextQuery = {
...queries[index],
edited,
query: value,
};
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
if (override) { if (override) {
queryErrors = []; const { queries } = this.state;
queryHints = []; const nextQuery: Query = {
key: generateQueryKey(index),
query: value,
};
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
this.setState(
{
queryErrors: [],
queryHints: [],
queries: nextQueries,
},
this.onSubmit
);
} }
this.setState(
{
queryErrors,
queryHints,
queries: nextQueries,
},
override ? () => this.onSubmit() : undefined
);
}; };
onChangeTime = nextRange => { onChangeTime = nextRange => {
@ -261,6 +256,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onClickClear = () => { onClickClear = () => {
this.queryExpressions = [''];
this.setState( this.setState(
{ {
graphResult: null, graphResult: null,
@ -293,9 +289,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onClickSplit = () => { onClickSplit = () => {
const { onChangeSplit } = this.props; const { onChangeSplit } = this.props;
const state = { ...this.state };
state.queries = state.queries.map(({ edited, ...rest }) => rest);
if (onChangeSplit) { if (onChangeSplit) {
const state = this.cloneState();
onChangeSplit(true, state); onChangeSplit(true, state);
this.saveState(); this.saveState();
} }
@ -315,23 +310,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
let nextQueries; let nextQueries;
if (index === undefined) { if (index === undefined) {
// Modify all queries // Modify all queries
nextQueries = queries.map(q => ({ nextQueries = queries.map((q, i) => ({
...q, key: generateQueryKey(i),
edited: false, query: datasource.modifyQuery(this.queryExpressions[i], action),
query: datasource.modifyQuery(q.query, action),
})); }));
} else { } else {
// Modify query only at index // Modify query only at index
nextQueries = [ nextQueries = [
...queries.slice(0, index), ...queries.slice(0, index),
{ {
...queries[index], key: generateQueryKey(index),
edited: false, query: datasource.modifyQuery(this.queryExpressions[index], action),
query: datasource.modifyQuery(queries[index].query, action),
}, },
...queries.slice(index + 1), ...queries.slice(index + 1),
]; ];
} }
this.queryExpressions = nextQueries.map(q => q.query);
this.setState({ queries: nextQueries }, () => this.onSubmit()); this.setState({ queries: nextQueries }, () => this.onSubmit());
} }
}; };
@ -342,6 +336,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return; return;
} }
const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
this.queryExpressions = nextQueries.map(q => q.query);
this.setState({ queries: nextQueries }, () => this.onSubmit()); this.setState({ queries: nextQueries }, () => this.onSubmit());
}; };
@ -359,7 +354,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.saveState(); this.saveState();
}; };
onQuerySuccess(datasourceId: string, queries: any[]): void { onQuerySuccess(datasourceId: string, queries: string[]): void {
// save queries to history // save queries to history
let { history } = this.state; let { history } = this.state;
const { datasource } = this.state; const { datasource } = this.state;
@ -370,8 +365,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
const ts = Date.now(); const ts = Date.now();
queries.forEach(q => { queries.forEach(query => {
const { query } = q;
history = [{ query, ts }, ...history]; history = [{ query, ts }, ...history];
}); });
@ -386,16 +380,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) { buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
const { datasource, queries, range } = this.state; const { datasource, range } = this.state;
const resolution = this.el.offsetWidth; const resolution = this.el.offsetWidth;
const absoluteRange = { const absoluteRange = {
from: parseDate(range.from, false), from: parseDate(range.from, false),
to: parseDate(range.to, true), to: parseDate(range.to, true),
}; };
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
const targets = queries.map(q => ({ const targets = this.queryExpressions.map(q => ({
...targetOptions, ...targetOptions,
expr: q.query, expr: q,
})); }));
return { return {
interval, interval,
@ -405,7 +399,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
async runGraphQuery() { async runGraphQuery() {
const { datasource, queries } = this.state; const { datasource } = this.state;
const queries = [...this.queryExpressions];
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
@ -427,7 +422,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
async runTableQuery() { async runTableQuery() {
const { datasource, queries } = this.state; const queries = [...this.queryExpressions];
const { datasource } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
@ -451,7 +447,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
async runLogsQuery() { async runLogsQuery() {
const { datasource, queries } = this.state; const queries = [...this.queryExpressions];
const { datasource } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
@ -479,18 +476,27 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return datasource.metadataRequest(url); return datasource.metadataRequest(url);
}; };
cloneState(): ExploreState {
// Copy state, but copy queries including modifications
return {
...this.state,
queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
};
}
saveState = () => { saveState = () => {
const { stateKey, onSaveState } = this.props; const { stateKey, onSaveState } = this.props;
onSaveState(stateKey, this.state); onSaveState(stateKey, this.cloneState());
}; };
render() { render() {
const { datasourceSrv, position, split } = this.props; const { position, split } = this.props;
const { const {
datasource, datasource,
datasourceError, datasourceError,
datasourceLoading, datasourceLoading,
datasourceMissing, datasourceMissing,
exploreDatasources,
graphResult, graphResult,
history, history,
latency, latency,
@ -515,10 +521,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const logsButtonActive = showingLogs ? 'active' : ''; const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore'; const exploreClass = split ? 'explore explore-split' : 'explore';
const datasources = datasourceSrv.getExploreSources().map(ds => ({
value: ds.name,
label: ds.name,
}));
const selectedDatasource = datasource ? datasource.name : undefined; const selectedDatasource = datasource ? datasource.name : undefined;
return ( return (
@ -544,7 +546,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
clearable={false} clearable={false}
className="gf-form-input gf-form-input--form-dropdown datasource-picker" className="gf-form-input gf-form-input--form-dropdown datasource-picker"
onChange={this.onChangeDatasource} onChange={this.onChangeDatasource}
options={datasources} options={exploreDatasources}
isOpen={true} isOpen={true}
placeholder="Loading datasources..." placeholder="Loading datasources..."
value={selectedDatasource} value={selectedDatasource}

View File

@ -156,6 +156,7 @@ interface PromQueryFieldState {
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[]; logLabelOptions: any[];
metrics: string[]; metrics: string[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[]; metricsByPrefix: CascaderOption[];
} }
@ -167,7 +168,7 @@ interface PromTypeaheadInput {
value?: Value; value?: Value;
} }
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> { class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[]; plugins: any[];
constructor(props: PromQueryFieldProps, context) { constructor(props: PromQueryFieldProps, context) {
@ -189,6 +190,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
logLabelOptions: [], logLabelOptions: [],
metrics: props.metrics || [], metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [], metricsByPrefix: props.metricsByPrefix || [],
metricsOptions: [],
}; };
} }
@ -258,10 +260,22 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
}; };
onReceiveMetrics = () => { onReceiveMetrics = () => {
if (!this.state.metrics) { const { histogramMetrics, metrics, metricsByPrefix } = this.state;
if (!metrics) {
return; return;
} }
// Update global prism config
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics); setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
// Build metrics tree
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
this.setState({ metricsOptions });
}; };
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
@ -453,7 +467,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR]; const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) { if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort(); const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics }); this.setState({ histogramMetrics }, this.onReceiveMetrics);
} }
}); });
} }
@ -545,12 +559,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
render() { render() {
const { error, hint, supportsLogs } = this.props; const { error, hint, supportsLogs } = this.props;
const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state; const { logLabelOptions, metricsOptions } = this.state;
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
return ( return (
<div className="prom-query-field"> <div className="prom-query-field">
@ -575,6 +584,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
onWillApplySuggestion={willApplySuggestion} onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery} onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query" placeholder="Enter a PromQL query"
portalPrefix="prometheus"
/> />
</div> </div>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null} {error ? <div className="prom-query-field-info text-error">{error}</div> : null}

View File

@ -11,10 +11,17 @@ import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead'; import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value'; import { makeFragment, makeValue } from './Value';
export const TYPEAHEAD_DEBOUNCE = 300; export const TYPEAHEAD_DEBOUNCE = 100;
function flattenSuggestions(s: any[]): any[] { function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : []; // Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex];
}
function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
return suggestions && suggestions.length > 0;
} }
export interface Suggestion { export interface Suggestion {
@ -125,7 +132,7 @@ export interface TypeaheadOutput {
suggestions: SuggestionGroup[]; suggestions: SuggestionGroup[];
} }
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> { class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null; menuEl: HTMLElement | null;
plugins: any[]; plugins: any[];
resetTimer: any; resetTimer: any;
@ -154,8 +161,14 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
clearTimeout(this.resetTimer); clearTimeout(this.resetTimer);
} }
componentDidUpdate() { componentDidUpdate(prevProps, prevState) {
this.updateMenu(); // Only update menu location when suggestion existence or text/selection changed
if (
this.state.value !== prevState.value ||
hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
) {
this.updateMenu();
}
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
@ -166,15 +179,21 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
} }
onChange = ({ value }) => { onChange = ({ value }) => {
const changed = value.document !== this.state.value.document; const textChanged = value.document !== this.state.value.document;
// Control editor loop, then pass text change up to parent
this.setState({ value }, () => { this.setState({ value }, () => {
if (changed) { if (textChanged) {
this.handleChangeValue(); this.handleChangeValue();
} }
}); });
if (changed) { // Show suggest menu on text input
if (textChanged && value.selection.isCollapsed) {
// Need one paint to allow DOM-based typeahead rules to work
window.requestAnimationFrame(this.handleTypeahead); window.requestAnimationFrame(this.handleTypeahead);
} else {
this.resetTypeahead();
} }
}; };
@ -216,7 +235,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
wrapperNode, wrapperNode,
}); });
const filteredSuggestions = suggestions let filteredSuggestions = suggestions
.map(group => { .map(group => {
if (group.items) { if (group.items) {
if (prefix) { if (prefix) {
@ -241,6 +260,11 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
}) })
.filter(group => group.items && group.items.length > 0); // Filter out empty groups .filter(group => group.items && group.items.length > 0); // Filter out empty groups
// Keep same object for equality checking later
if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
filteredSuggestions = this.state.suggestions;
}
this.setState( this.setState(
{ {
suggestions: filteredSuggestions, suggestions: filteredSuggestions,
@ -326,12 +350,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
return undefined; return undefined;
} }
// Get the currently selected suggestion const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
const flattenedSuggestions = flattenSuggestions(suggestions);
const selected = Math.abs(typeaheadIndex);
const selectedIndex = selected % flattenedSuggestions.length || 0;
const suggestion = flattenedSuggestions[selectedIndex];
this.applyTypeahead(change, suggestion); this.applyTypeahead(change, suggestion);
return true; return true;
} }
@ -408,8 +427,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
} }
// No suggestions or blur, remove menu // No suggestions or blur, remove menu
const hasSuggesstions = suggestions && suggestions.length > 0; if (!hasSuggestions(suggestions)) {
if (!hasSuggesstions) {
menu.removeAttribute('style'); menu.removeAttribute('style');
return; return;
} }
@ -436,18 +454,12 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
renderMenu = () => { renderMenu = () => {
const { portalPrefix } = this.props; const { portalPrefix } = this.props;
const { suggestions } = this.state; const { suggestions, typeaheadIndex } = this.state;
const hasSuggesstions = suggestions && suggestions.length > 0; if (!hasSuggestions(suggestions)) {
if (!hasSuggesstions) {
return null; return null;
} }
// Guard selectedIndex to be within the length of the suggestions const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
const flattenedSuggestions = flattenSuggestions(suggestions);
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
const selectedItem: Suggestion | null =
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
// Create typeahead in DOM root so we can later position it absolutely // Create typeahead in DOM root so we can later position it absolutely
return ( return (
@ -482,7 +494,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
} }
} }
class Portal extends React.Component<{ index?: number; prefix: string }, {}> { class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
node: HTMLElement; node: HTMLElement;
constructor(props) { constructor(props) {

View File

@ -44,14 +44,14 @@ class QueryRow extends PureComponent<any, {}> {
}; };
render() { render() {
const { edited, history, query, queryError, queryHint, request, supportsLogs } = this.props; const { history, query, queryError, queryHint, request, supportsLogs } = this.props;
return ( return (
<div className="query-row"> <div className="query-row">
<div className="query-row-field"> <div className="query-row-field">
<QueryField <QueryField
error={queryError} error={queryError}
hint={queryHint} hint={queryHint}
initialQuery={edited ? null : query} initialQuery={query}
history={history} history={history}
portalPrefix="explore" portalPrefix="explore"
onClickHintFix={this.onClickHintFix} onClickHintFix={this.onClickHintFix}
@ -79,7 +79,7 @@ class QueryRow extends PureComponent<any, {}> {
export default class QueryRows extends PureComponent<any, {}> { export default class QueryRows extends PureComponent<any, {}> {
render() { render() {
const { className = '', queries, queryErrors = [], queryHints = [], ...handlers } = this.props; const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props;
return ( return (
<div className={className}> <div className={className}>
{queries.map((q, index) => ( {queries.map((q, index) => (
@ -89,7 +89,6 @@ export default class QueryRows extends PureComponent<any, {}> {
query={q.query} query={q.query}
queryError={queryErrors[index]} queryError={queryErrors[index]}
queryHint={queryHints[index]} queryHint={queryHints[index]}
edited={q.edited}
{...handlers} {...handlers}
/> />
))} ))}

View File

@ -5,7 +5,6 @@ import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil'; import * as rangeUtil from 'app/core/utils/rangeutil';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DEFAULT_RANGE = { export const DEFAULT_RANGE = {
from: 'now-6h', from: 'now-6h',
to: 'now', to: 'now',

View File

@ -23,7 +23,9 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) { if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el); requestAnimationFrame(() => {
scrollIntoView(this.el);
});
} }
} }

View File

@ -3,31 +3,11 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreUrlState } from 'app/types/explore'; import { ExploreState } from 'app/types/explore';
import Explore, { ExploreState } from './Explore'; import Explore from './Explore';
import { DEFAULT_RANGE } from './TimePicker';
export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) {
try {
return JSON.parse(decodeURI(initial));
} catch (e) {
console.error(e);
}
}
return { datasource: null, queries: [], range: DEFAULT_RANGE };
}
export function serializeStateToUrlParam(state: ExploreState): string {
const urlState: ExploreUrlState = {
datasource: state.datasourceName,
queries: state.queries.map(q => ({ query: q.query })),
range: state.range,
};
return JSON.stringify(urlState);
}
interface WrapperProps { interface WrapperProps {
backendSrv?: any; backendSrv?: any;

View File

@ -1,14 +1,16 @@
export function generateQueryKey(index = 0) { import { Query } from 'app/types/explore';
export function generateQueryKey(index = 0): string {
return `Q-${Date.now()}-${Math.random()}-${index}`; return `Q-${Date.now()}-${Math.random()}-${index}`;
} }
export function ensureQueries(queries?) { export function ensureQueries(queries?: Query[]): Query[] {
if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') { if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') {
return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query })); return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query }));
} }
return [{ key: generateQueryKey(), query: '' }]; return [{ key: generateQueryKey(), query: '' }];
} }
export function hasQuery(queries) { export function hasQuery(queries: string[]): boolean {
return queries.some(q => q.query); return queries.some(q => Boolean(q));
} }

View File

@ -110,7 +110,7 @@ export function updateFolderPermission(itemToUpdate: DashboardAcl, level: Permis
continue; continue;
} }
const updated = toUpdateItem(itemToUpdate); const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion // if this is the item we want to update, update it's permisssion
if (itemToUpdate === item) { if (itemToUpdate === item) {

View File

@ -1,6 +1,4 @@
import './org_users_ctrl';
import './profile_ctrl'; import './profile_ctrl';
import './org_users_ctrl';
import './select_org_ctrl'; import './select_org_ctrl';
import './change_password_ctrl'; import './change_password_ctrl';
import './new_org_ctrl'; import './new_org_ctrl';

View File

@ -1,87 +0,0 @@
import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import Remarkable from 'remarkable';
import _ from 'lodash';
export class OrgUsersCtrl {
unfiltered: any;
users: any;
pendingInvites: any;
editor: any;
navModel: any;
externalUserMngLinkUrl: string;
externalUserMngLinkName: string;
externalUserMngInfo: string;
canInvite: boolean;
searchQuery: string;
showInvites: boolean;
/** @ngInject */
constructor(private $scope, private backendSrv, navModelSrv, $sce) {
this.navModel = navModelSrv.getNav('cfg', 'users', 0);
this.get();
this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
this.externalUserMngLinkName = config.externalUserMngLinkName;
this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
// render external user management info markdown
if (config.externalUserMngInfo) {
this.externalUserMngInfo = new Remarkable({
linkTarget: '__blank',
}).render(config.externalUserMngInfo);
}
}
get() {
this.backendSrv.get('/api/org/users').then(users => {
this.users = users;
this.unfiltered = users;
});
this.backendSrv.get('/api/org/invites').then(pendingInvites => {
this.pendingInvites = pendingInvites;
});
}
onQueryUpdated() {
const regex = new RegExp(this.searchQuery, 'ig');
this.users = _.filter(this.unfiltered, item => {
return regex.test(item.email) || regex.test(item.login);
});
}
updateOrgUser(user) {
this.backendSrv.patch('/api/org/users/' + user.userId, user);
}
removeUser(user) {
this.$scope.appEvent('confirm-modal', {
title: 'Delete',
text: 'Are you sure you want to delete user ' + user.login + '?',
yesText: 'Delete',
icon: 'fa-warning',
onConfirm: () => {
this.removeUserConfirmed(user);
},
});
}
removeUserConfirmed(user) {
this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
}
revokeInvite(invite, evt) {
evt.stopPropagation();
this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
}
copyInviteToClipboard(evt) {
evt.stopPropagation();
}
getInviteUrl(invite) {
return invite.url;
}
}
coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);

View File

@ -1,105 +0,0 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<div class="page-action-bar">
<label class="gf-form gf-form--has-input-icon">
<input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
<button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
Users
</button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
Pending Invites ({{ctrl.pendingInvites.length}})
</button>
<button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
Users
</button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
Pending Invites ({{ctrl.pendingInvites.length}})
</button>
</div>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
<i class="fa fa-plus"></i>
<span>Invite</span>
</a>
<a class="btn btn-success" ng-href="{{ctrl.externalUserMngLinkUrl}}" target="_blank" ng-if="ctrl.externalUserMngLinkUrl">
<i class="fa fa-external-link-square"></i>
{{ctrl.externalUserMngLinkName}}
</a>
</div>
<div class="grafana-info-box" ng-if="ctrl.externalUserMngInfo">
<span ng-bind-html="ctrl.externalUserMngInfo"></span>
</div>
<div ng-hide="ctrl.showInvites">
<table class="filter-table form-inline">
<thead>
<tr>
<th></th>
<th>Login</th>
<th>Email</th>
<th>
Seen
<tip>Time since user was seen using Grafana</tip>
</th>
<th>Role</th>
<th style="width: 34px;"></th>
</tr>
</thead>
<tr ng-repeat="user in ctrl.users">
<td class="width-4 text-center">
<img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
</td>
<td>{{user.login}}</td>
<td><span class="ellipsis">{{user.email}}</span></td>
<td>{{user.lastSeenAtAge}}</td>
<td>
<div class="gf-form-select-wrapper width-12">
<select type="text" ng-model="user.role" class="gf-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
</select>
</div>
</td>
<td>
<a ng-click="ctrl.removeUser(user)" class="btn btn-danger btn-mini">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
</table>
</div>
<div ng-if="ctrl.showInvites">
<table class="filter-table form-inline">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th></th>
<th style="width: 34px;"></th>
</tr>
</thead>
<tr ng-repeat="invite in ctrl.pendingInvites">
<td>{{invite.email}}</td>
<td>{{invite.name}}</td>
<td class="text-right">
<button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
<i class="fa fa-clipboard"></i> Copy Invite
</button>
&nbsp;
</td>
<td>
<button class="btn btn-danger btn-mini" ng-click="ctrl.revokeInvite(invite, $event)">
<i class="fa fa-remove"></i>
</button>
</td>
</tr>
</table>
</div>
</div>

View File

@ -6,7 +6,7 @@ import kbn from 'app/core/utils/kbn';
import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import * as rangeUtil from 'app/core/utils/rangeutil'; import * as rangeUtil from 'app/core/utils/rangeutil';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import { renderUrl } from 'app/core/utils/url'; import { getExploreUrl } from 'app/core/utils/explore';
import { metricsTabDirective } from './metrics_tab'; import { metricsTabDirective } from './metrics_tab';
@ -314,7 +314,12 @@ class MetricsPanelCtrl extends PanelCtrl {
getAdditionalMenuItems() { getAdditionalMenuItems() {
const items = []; const items = [];
if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource && this.datasource.supportsExplore) { if (
config.exploreEnabled &&
this.contextSrv.isEditor &&
this.datasource &&
(this.datasource.meta.explore || this.datasource.meta.id === 'mixed')
) {
items.push({ items.push({
text: 'Explore', text: 'Explore',
click: 'ctrl.explore();', click: 'ctrl.explore();',
@ -325,14 +330,11 @@ class MetricsPanelCtrl extends PanelCtrl {
return items; return items;
} }
explore() { async explore() {
const range = this.timeSrv.timeRangeForUrl(); const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
const state = { if (url) {
...this.datasource.getExploreState(this.panel), this.$timeout(() => this.$location.url(url));
range, }
};
const exploreState = JSON.stringify(state);
this.$location.url(renderUrl('/explore', { state: exploreState }));
} }
addQuery(target) { addQuery(target) {

View File

@ -38,7 +38,7 @@ describe('MetricsPanelCtrl', () => {
describe('and has datasource set that supports explore and user has powers', () => { describe('and has datasource set that supports explore and user has powers', () => {
beforeEach(() => { beforeEach(() => {
ctrl.contextSrv = { isEditor: true }; ctrl.contextSrv = { isEditor: true };
ctrl.datasource = { supportsExplore: true }; ctrl.datasource = { meta: { explore: true } };
additionalItems = ctrl.getAdditionalMenuItems(); additionalItems = ctrl.getAdditionalMenuItems();
}); });

View File

@ -1,31 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { PluginActionBar, Props } from './PluginActionBar';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
const setup = (propOverrides?: object) => {
const props: Props = {
searchQuery: '',
layoutMode: LayoutModes.Grid,
setLayoutMode: jest.fn(),
setPluginsSearchQuery: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<PluginActionBar {...props} />);
const instance = wrapper.instance() as PluginActionBar;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,62 +0,0 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
export interface Props {
searchQuery: string;
layoutMode: LayoutMode;
setLayoutMode: typeof setLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
}
export class PluginActionBar extends PureComponent<Props> {
onSearchQueryChange = event => {
this.props.setPluginsSearchQuery(event.target.value);
};
render() {
const { searchQuery, layoutMode, setLayoutMode } = this.props;
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={this.onSearchQueryChange}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
</div>
<div className="page-action-bar__spacer" />
<a
className="btn btn-success"
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
target="_blank"
>
Find more plugins on Grafana.com
</a>
</div>
);
}
}
function mapStateToProps(state) {
return {
searchQuery: getPluginsSearchQuery(state.plugins),
layoutMode: getLayoutMode(state.plugins),
};
}
const mapDispatchToProps = {
setPluginsSearchQuery,
setLayoutMode,
};
export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);

View File

@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
const props: Props = { const props: Props = {
navModel: {} as NavModel, navModel: {} as NavModel,
plugins: [] as Plugin[], plugins: [] as Plugin[],
searchQuery: '',
setPluginsSearchQuery: jest.fn(),
setPluginsLayoutMode: jest.fn(),
layoutMode: LayoutModes.Grid, layoutMode: LayoutModes.Grid,
loadPlugins: jest.fn(), loadPlugins: jest.fn(),
}; };

View File

@ -1,20 +1,23 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PageHeader from '../../core/components/PageHeader/PageHeader'; import PageHeader from 'app/core/components/PageHeader/PageHeader';
import PluginActionBar from './PluginActionBar'; import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
import PluginList from './PluginList'; import PluginList from './PluginList';
import { NavModel, Plugin } from '../../types'; import { NavModel, Plugin } from 'app/types';
import { loadPlugins } from './state/actions'; import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
import { getNavModel } from '../../core/selectors/navModel'; import { getNavModel } from '../../core/selectors/navModel';
import { getLayoutMode, getPlugins } from './state/selectors'; import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
export interface Props { export interface Props {
navModel: NavModel; navModel: NavModel;
plugins: Plugin[]; plugins: Plugin[];
layoutMode: LayoutMode; layoutMode: LayoutMode;
searchQuery: string;
loadPlugins: typeof loadPlugins; loadPlugins: typeof loadPlugins;
setPluginsLayoutMode: typeof setPluginsLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
} }
export class PluginListPage extends PureComponent<Props> { export class PluginListPage extends PureComponent<Props> {
@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
} }
render() { render() {
const { navModel, plugins, layoutMode } = this.props; const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
return ( return (
<div> <div>
<PageHeader model={navModel} /> <PageHeader model={navModel} />
<div className="page-container page-body"> <div className="page-container page-body">
<PluginActionBar /> <OrgActionBar
searchQuery={searchQuery}
layoutMode={layoutMode}
onSetLayoutMode={mode => setPluginsLayoutMode(mode)}
setSearchQuery={query => setPluginsSearchQuery(query)}
linkButton={linkButton}
/>
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />} {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
</div> </div>
</div> </div>
@ -46,11 +59,14 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'plugins'), navModel: getNavModel(state.navIndex, 'plugins'),
plugins: getPlugins(state.plugins), plugins: getPlugins(state.plugins),
layoutMode: getLayoutMode(state.plugins), layoutMode: getLayoutMode(state.plugins),
searchQuery: getPluginsSearchQuery(state.plugins),
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadPlugins, loadPlugins,
setPluginsLayoutMode,
setPluginsSearchQuery,
}; };
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage)); export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

View File

@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
<div <div
className="page-container page-body" className="page-container page-body"
> >
<Connect(PluginActionBar) /> <OrgActionBar
layoutMode="grid"
linkButton={
Object {
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
"title": "Find more plugins on Grafana.com",
}
}
onSetLayoutMode={[Function]}
searchQuery=""
setSearchQuery={[Function]}
/>
<PluginList <PluginList
layoutMode="grid" layoutMode="grid"
plugins={Array []} plugins={Array []}

View File

@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
payload: LayoutMode; payload: LayoutMode;
} }
export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({ export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
type: ActionTypes.SetLayoutMode, type: ActionTypes.SetLayoutMode,
payload: mode, payload: mode,
}); });

View File

@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from 'enzyme';
import InviteesTable, { Props } from './InviteesTable';
import { Invitee } from 'app/types';
import { getMockInvitees } from './__mocks__/userMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
invitees: [] as Invitee[],
onRevokeInvite: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<InviteesTable {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render invitees', () => {
const wrapper = setup({
invitees: getMockInvitees(5),
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,64 @@
import React, { createRef, PureComponent } from 'react';
import { Invitee } from 'app/types';
export interface Props {
invitees: Invitee[];
onRevokeInvite: (code: string) => void;
}
export default class InviteesTable extends PureComponent<Props> {
private copyUrlRef = createRef<HTMLTextAreaElement>();
copyToClipboard = () => {
const node = this.copyUrlRef.current;
if (node) {
node.select();
document.execCommand('copy');
}
};
render() {
const { invitees, onRevokeInvite } = this.props;
return (
<table className="filter-table form-inline">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th />
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{invitees.map((invitee, index) => {
return (
<tr key={`${invitee.id}-${index}`}>
<td>{invitee.email}</td>
<td>{invitee.name}</td>
<td className="text-right">
<button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
<textarea
readOnly={true}
value={invitee.url}
style={{ position: 'absolute', right: -1000 }}
ref={this.copyUrlRef}
/>
<i className="fa fa-clipboard" /> Copy Invite
</button>
&nbsp;
</td>
<td>
<button className="btn btn-danger btn-mini" onClick={() => onRevokeInvite(invitee.code)}>
<i className="fa fa-remove" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UsersActionBar, Props } from './UsersActionBar';
const setup = (propOverrides?: object) => {
const props: Props = {
searchQuery: '',
setUsersSearchQuery: jest.fn(),
onShowInvites: jest.fn(),
pendingInvitesCount: 0,
canInvite: false,
externalUserMngLinkUrl: '',
externalUserMngLinkName: '',
showInvites: false,
};
Object.assign(props, propOverrides);
return shallow(<UsersActionBar {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render pending invites button', () => {
const wrapper = setup({
pendingInvitesCount: 5,
});
expect(wrapper).toMatchSnapshot();
});
it('should show invite button', () => {
const wrapper = setup({
canInvite: true,
});
expect(wrapper).toMatchSnapshot();
});
it('should show external user management button', () => {
const wrapper = setup({
externalUserMngLinkUrl: 'some/url',
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,97 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames/bind';
import { setUsersSearchQuery } from './state/actions';
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
export interface Props {
searchQuery: string;
setUsersSearchQuery: typeof setUsersSearchQuery;
onShowInvites: () => void;
pendingInvitesCount: number;
canInvite: boolean;
showInvites: boolean;
externalUserMngLinkUrl: string;
externalUserMngLinkName: string;
}
export class UsersActionBar extends PureComponent<Props> {
render() {
const {
canInvite,
externalUserMngLinkName,
externalUserMngLinkUrl,
searchQuery,
pendingInvitesCount,
setUsersSearchQuery,
onShowInvites,
showInvites,
} = this.props;
const pendingInvitesButtonStyle = classNames({
btn: true,
'toggle-btn': true,
active: showInvites,
});
const usersButtonStyle = classNames({
btn: true,
'toggle-btn': true,
active: !showInvites,
});
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={event => setUsersSearchQuery(event.target.value)}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
{pendingInvitesCount > 0 && (
<div style={{ marginLeft: '1rem' }}>
<button className={usersButtonStyle} key="users" onClick={onShowInvites}>
Users
</button>
<button className={pendingInvitesButtonStyle} onClick={onShowInvites} key="pending-invites">
Pending Invites ({pendingInvitesCount})
</button>
</div>
)}
<div className="page-action-bar__spacer" />
{canInvite && (
<a className="btn btn-success" href="org/users/invite">
<span>Invite</span>
</a>
)}
{externalUserMngLinkUrl && (
<a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
<i className="fa fa-external-link-square" /> {externalUserMngLinkName}
</a>
)}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
searchQuery: getUsersSearchQuery(state.users),
pendingInvitesCount: getInviteesCount(state.users),
externalUserMngLinkName: state.users.externalUserMngLinkName,
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
canInvite: state.users.canInvite,
};
}
const mapDispatchToProps = {
setUsersSearchQuery,
};
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);

View File

@ -0,0 +1,55 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UsersListPage, Props } from './UsersListPage';
import { Invitee, NavModel, OrgUser } from 'app/types';
import { getMockUser } from './__mocks__/userMocks';
import appEvents from '../../core/app_events';
jest.mock('../../core/app_events', () => ({
emit: jest.fn(),
}));
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
users: [] as OrgUser[],
invitees: [] as Invitee[],
searchQuery: '',
externalUserMngInfo: '',
revokeInvite: jest.fn(),
loadInvitees: jest.fn(),
loadUsers: jest.fn(),
updateUser: jest.fn(),
removeUser: jest.fn(),
setUsersSearchQuery: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<UsersListPage {...props} />);
const instance = wrapper.instance() as UsersListPage;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
it('should emit show remove user modal', () => {
const { instance } = setup();
const mockUser = getMockUser();
instance.onRemoveUser(mockUser);
expect(appEvents.emit).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,136 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import Remarkable from 'remarkable';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import UsersActionBar from './UsersActionBar';
import UsersTable from 'app/features/users/UsersTable';
import InviteesTable from './InviteesTable';
import { Invitee, NavModel, OrgUser } from 'app/types';
import appEvents from 'app/core/app_events';
import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
import { getNavModel } from '../../core/selectors/navModel';
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
export interface Props {
navModel: NavModel;
invitees: Invitee[];
users: OrgUser[];
searchQuery: string;
externalUserMngInfo: string;
loadUsers: typeof loadUsers;
loadInvitees: typeof loadInvitees;
setUsersSearchQuery: typeof setUsersSearchQuery;
updateUser: typeof updateUser;
removeUser: typeof removeUser;
revokeInvite: typeof revokeInvite;
}
export interface State {
showInvites: boolean;
}
export class UsersListPage extends PureComponent<Props, State> {
externalUserMngInfoHtml: string;
constructor(props) {
super(props);
if (this.props.externalUserMngInfo) {
const markdownRenderer = new Remarkable();
this.externalUserMngInfoHtml = markdownRenderer.render(this.props.externalUserMngInfo);
}
this.state = {
showInvites: false,
};
}
componentDidMount() {
this.fetchUsers();
this.fetchInvitees();
}
async fetchUsers() {
return await this.props.loadUsers();
}
async fetchInvitees() {
return await this.props.loadInvitees();
}
onRoleChange = (role, user) => {
const updatedUser = { ...user, role: role };
this.props.updateUser(updatedUser);
};
onRemoveUser = user => {
appEvents.emit('confirm-modal', {
title: 'Delete',
text: 'Are you sure you want to delete user ' + user.login + '?',
yesText: 'Delete',
icon: 'fa-warning',
onConfirm: () => {
this.props.removeUser(user.userId);
},
});
};
onRevokeInvite = code => {
this.props.revokeInvite(code);
};
onShowInvites = () => {
this.setState(prevState => ({
showInvites: !prevState.showInvites,
}));
};
render() {
const { invitees, navModel, users } = this.props;
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
{externalUserMngInfoHtml && (
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
)}
{this.state.showInvites ? (
<InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />
) : (
<UsersTable
users={users}
onRoleChange={(role, user) => this.onRoleChange(role, user)}
onRemoveUser={user => this.onRemoveUser(user)}
/>
)}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
navModel: getNavModel(state.navIndex, 'users'),
users: getUsers(state.users),
searchQuery: getUsersSearchQuery(state.users),
invitees: getInvitees(state.users),
externalUserMngInfo: state.users.externalUserMngInfo,
};
}
const mapDispatchToProps = {
loadUsers,
loadInvitees,
setUsersSearchQuery,
updateUser,
removeUser,
revokeInvite,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));

View File

@ -0,0 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import UsersTable, { Props } from './UsersTable';
import { OrgUser } from 'app/types';
import { getMockUsers } from './__mocks__/userMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
users: [] as OrgUser[],
onRoleChange: jest.fn(),
onRemoveUser: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<UsersTable {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render users table', () => {
const wrapper = setup({
users: getMockUsers(5),
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,67 @@
import React, { SFC } from 'react';
import { OrgUser } from 'app/types';
export interface Props {
users: OrgUser[];
onRoleChange: (role: string, user: OrgUser) => void;
onRemoveUser: (user: OrgUser) => void;
}
const UsersTable: SFC<Props> = props => {
const { users, onRoleChange, onRemoveUser } = props;
return (
<table className="filter-table form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Seen</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return (
<tr key={`${user.userId}-${index}`}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={user.avatarUrl} />
</td>
<td>{user.login}</td>
<td>
<span className="ellipsis">{user.email}</span>
</td>
<td>{user.lastSeenAtAge}</td>
<td>
<div className="gf-form-select-wrapper width-12">
<select
value={user.role}
className="gf-form-input"
onChange={event => onRoleChange(event.target.value, user)}
>
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</td>
<td>
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
);
};
export default UsersTable;

View File

@ -0,0 +1,56 @@
export const getMockUsers = (amount: number) => {
const users = [];
for (let i = 0; i <= amount; i++) {
users.push({
avatarUrl: 'url/to/avatar',
email: `user-${i}@test.com`,
lastSeenAt: '2018-10-01',
lastSeenAtAge: '',
login: `user-${i}`,
orgId: 1,
role: 'Admin',
userId: i,
});
}
return users;
};
export const getMockUser = () => {
return {
avatarUrl: 'url/to/avatar',
email: `user@test.com`,
lastSeenAt: '2018-10-01',
lastSeenAtAge: '',
login: `user`,
orgId: 1,
role: 'Admin',
userId: 2,
};
};
export const getMockInvitees = (amount: number) => {
const invitees = [];
for (let i = 0; i <= amount; i++) {
invitees.push({
code: `asdfasdfsadf-${i}`,
createdOn: '2018-10-02',
email: `invitee-${i}@test.com`,
emailSent: true,
emailSentOn: '2018-10-02',
id: i,
invitedByEmail: 'admin@grafana.com',
invitedByLogin: 'admin',
invitedByName: 'admin',
name: `invitee-${i}`,
orgId: 1,
role: 'viewer',
status: 'not accepted',
url: `localhost/invite/$${i}`,
});
}
return invitees;
};

View File

@ -0,0 +1,318 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th>
Email
</th>
<th>
Name
</th>
<th />
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody />
</table>
`;
exports[`Render should render invitees 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th>
Email
</th>
<th>
Name
</th>
<th />
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="0-0"
>
<td>
invitee-0@test.com
</td>
<td>
invitee-0
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$0"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
<tr
key="1-1"
>
<td>
invitee-1@test.com
</td>
<td>
invitee-1
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$1"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
<tr
key="2-2"
>
<td>
invitee-2@test.com
</td>
<td>
invitee-2
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$2"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
<tr
key="3-3"
>
<td>
invitee-3@test.com
</td>
<td>
invitee-3
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$3"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
<tr
key="4-4"
>
<td>
invitee-4@test.com
</td>
<td>
invitee-4
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$4"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
<tr
key="5-5"
>
<td>
invitee-5@test.com
</td>
<td>
invitee-5
</td>
<td
className="text-right"
>
<button
className="btn btn-inverse btn-mini"
onClick={[Function]}
>
<textarea
readOnly={true}
style={
Object {
"position": "absolute",
"right": -1000,
}
}
value="localhost/invite/$5"
/>
<i
className="fa fa-clipboard"
/>
Copy Invite
</button>
 
</td>
<td>
<button
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</button>
</td>
</tr>
</tbody>
</table>
`;

View File

@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon"
>
<input
className="gf-form-input width-20"
onChange={[Function]}
placeholder="Filter by name or type"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<div
className="page-action-bar__spacer"
/>
</div>
</div>
`;
exports[`Render should render pending invites button 1`] = `
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon"
>
<input
className="gf-form-input width-20"
onChange={[Function]}
placeholder="Filter by name or type"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<div
style={
Object {
"marginLeft": "1rem",
}
}
>
<button
className="btn toggle-btn active"
key="users"
onClick={[MockFunction]}
>
Users
</button>
<button
className="btn toggle-btn"
key="pending-invites"
onClick={[MockFunction]}
>
Pending Invites (
5
)
</button>
</div>
<div
className="page-action-bar__spacer"
/>
</div>
</div>
`;
exports[`Render should show external user management button 1`] = `
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon"
>
<input
className="gf-form-input width-20"
onChange={[Function]}
placeholder="Filter by name or type"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-success"
href="some/url"
target="_blank"
>
<i
className="fa fa-external-link-square"
/>
</a>
</div>
</div>
`;
exports[`Render should show invite button 1`] = `
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon"
>
<input
className="gf-form-input width-20"
onChange={[Function]}
placeholder="Filter by name or type"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-success"
href="org/users/invite"
>
<span>
Invite
</span>
</a>
</div>
</div>
`;

View File

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<Connect(UsersActionBar)
onShowInvites={[Function]}
showInvites={false}
/>
<UsersTable
onRemoveUser={[Function]}
onRoleChange={[Function]}
users={Array []}
/>
</div>
</div>
`;

View File

@ -0,0 +1,444 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th />
<th>
Login
</th>
<th>
Email
</th>
<th>
Seen
</th>
<th>
Role
</th>
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody />
</table>
`;
exports[`Render should render users table 1`] = `
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th />
<th>
Login
</th>
<th>
Email
</th>
<th>
Seen
</th>
<th>
Role
</th>
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="0-0"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-0
</td>
<td>
<span
className="ellipsis"
>
user-0@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
<tr
key="1-1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-1
</td>
<td>
<span
className="ellipsis"
>
user-1@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
<tr
key="2-2"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-2
</td>
<td>
<span
className="ellipsis"
>
user-2@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
<tr
key="3-3"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-3
</td>
<td>
<span
className="ellipsis"
>
user-3@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
<tr
key="4-4"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-4
</td>
<td>
<span
className="ellipsis"
>
user-4@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
<tr
key="5-5"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="url/to/avatar"
/>
</td>
<td>
user-5
</td>
<td>
<span
className="ellipsis"
>
user-5@test.com
</span>
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
</td>
<td>
<div
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</div>
</td>
</tr>
</tbody>
</table>
`;

View File

@ -0,0 +1,79 @@
import { ThunkAction } from 'redux-thunk';
import { StoreState } from '../../../types';
import { getBackendSrv } from '../../../core/services/backend_srv';
import { Invitee, OrgUser } from 'app/types';
export enum ActionTypes {
LoadUsers = 'LOAD_USERS',
LoadInvitees = 'LOAD_INVITEES',
SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
}
export interface LoadUsersAction {
type: ActionTypes.LoadUsers;
payload: OrgUser[];
}
export interface LoadInviteesAction {
type: ActionTypes.LoadInvitees;
payload: Invitee[];
}
export interface SetUsersSearchQueryAction {
type: ActionTypes.SetUsersSearchQuery;
payload: string;
}
const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
type: ActionTypes.LoadUsers,
payload: users,
});
const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
type: ActionTypes.LoadInvitees,
payload: invitees,
});
export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
type: ActionTypes.SetUsersSearchQuery,
payload: query,
});
export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
export function loadUsers(): ThunkResult<void> {
return async dispatch => {
const users = await getBackendSrv().get('/api/org/users');
dispatch(usersLoaded(users));
};
}
export function loadInvitees(): ThunkResult<void> {
return async dispatch => {
const invitees = await getBackendSrv().get('/api/org/invites');
dispatch(inviteesLoaded(invitees));
};
}
export function updateUser(user: OrgUser): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
dispatch(loadUsers());
};
}
export function removeUser(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().delete(`/api/org/users/${userId}`);
dispatch(loadUsers());
};
}
export function revokeInvite(code: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
dispatch(loadInvitees());
};
}

View File

@ -0,0 +1,32 @@
import { Invitee, OrgUser, UsersState } from 'app/types';
import { Action, ActionTypes } from './actions';
import config from '../../../core/config';
export const initialState: UsersState = {
invitees: [] as Invitee[],
users: [] as OrgUser[],
searchQuery: '',
canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
externalUserMngInfo: config.externalUserMngInfo,
externalUserMngLinkName: config.externalUserMngLinkName,
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
};
export const usersReducer = (state = initialState, action: Action): UsersState => {
switch (action.type) {
case ActionTypes.LoadUsers:
return { ...state, users: action.payload };
case ActionTypes.LoadInvitees:
return { ...state, invitees: action.payload };
case ActionTypes.SetUsersSearchQuery:
return { ...state, searchQuery: action.payload };
}
return state;
};
export default {
users: usersReducer,
};

View File

@ -0,0 +1,18 @@
export const getUsers = state => {
const regex = new RegExp(state.searchQuery, 'i');
return state.users.filter(user => {
return regex.test(user.login) || regex.test(user.email);
});
};
export const getInvitees = state => {
const regex = new RegExp(state.searchQuery, 'i');
return state.invitees.filter(invitee => {
return regex.test(invitee.name) || regex.test(invitee.email);
});
};
export const getInviteesCount = state => state.invitees.length;
export const getUsersSearchQuery = state => state.searchQuery;

View File

@ -8,7 +8,6 @@ import * as templatingVariable from 'app/features/templating/variable';
export default class CloudWatchDatasource { export default class CloudWatchDatasource {
type: any; type: any;
name: any; name: any;
supportMetrics: any;
proxyUrl: any; proxyUrl: any;
defaultRegion: any; defaultRegion: any;
instanceSettings: any; instanceSettings: any;
@ -17,7 +16,6 @@ export default class CloudWatchDatasource {
constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) { constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
this.type = 'cloudwatch'; this.type = 'cloudwatch';
this.name = instanceSettings.name; this.name = instanceSettings.name;
this.supportMetrics = true;
this.proxyUrl = instanceSettings.url; this.proxyUrl = instanceSettings.url;
this.defaultRegion = instanceSettings.jsonData.defaultRegion; this.defaultRegion = instanceSettings.jsonData.defaultRegion;
this.instanceSettings = instanceSettings; this.instanceSettings = instanceSettings;

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