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
126 changed files with 6153 additions and 1578 deletions

View File

@@ -158,14 +158,18 @@ jobs:
name: sha-sum packages
command: 'go run build.go sha-dist'
- run:
name: Build Grafana.com publisher
name: Build Grafana.com master publisher
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:
root: .
paths:
- dist/grafana*
- scripts/*.sh
- scripts/publish
- scripts/build/release_publisher/release_publisher
build:
docker:
@@ -299,8 +303,8 @@ jobs:
name: deploy to s3
command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
- run:
name: Trigger Windows build
command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release'
name: Deploy to Grafana.com
command: './scripts/build/publish.sh'
workflows:
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_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-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)
### 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 = ["."]
revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
[[projects]]
branch = "master"
name = "github.com/VividCortex/mysqlerr"
packages = ["."]
revision = "6c6b55f8796f578c870b7e19bafb16103bc40095"
[[projects]]
name = "github.com/aws/aws-sdk-go"
packages = [
@@ -673,6 +679,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3"
inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -203,3 +203,7 @@ ignored = [
[[constraint]]
name = "github.com/denisenkom/go-mssqldb"
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:
- /var/run/docker.sock:/tmp/docker.sock:ro
mysql:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: grafana
MYSQL_USER: grafana
MYSQL_PASSWORD: password
ports:
- 3306
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s
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:
image: grafana/grafana:dev
volumes:
@@ -27,17 +42,23 @@ services:
environment:
- VIRTUAL_HOST=grafana.loc
- GF_SERVER_ROOT_URL=http://grafana.loc
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=mysql:3306
- GF_DATABASE_NAME=grafana
- GF_DATABASE_USER=grafana
- GF_DATABASE_PASSWORD=password
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=db:3306
- 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:
- 3000
depends_on:
mysql:
db:
condition: service_healthy
prometheus:

View File

@@ -127,10 +127,13 @@ Another way is put a webserver like Nginx or Apache in front of Grafana and have
### 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.
### socket
Path where the socket should be created when `protocol=socket`. Please make sure that Grafana has appropriate permissions.
### domain
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"
},
"name": "grafana",
"version": "5.3.0-pre1",
"version": "5.4.0-pre1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@@ -51,7 +51,21 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
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.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
NewStateDate time.Time
StateChanges int
StateChanges int64
Created time.Time
Updated time.Time
@@ -156,7 +156,7 @@ type SetAlertStateCommand struct {
Error string
EvalData *simplejson.Json
Timestamp time.Time
Result Alert
}
//Queries

View File

@@ -9,7 +9,17 @@ import (
var (
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 {
@@ -76,33 +86,34 @@ type GetAllAlertNotificationsQuery struct {
Result []*AlertNotification
}
type AlertNotificationJournal struct {
type AlertNotificationState struct {
Id int64
OrgId int64
AlertId int64
NotifierId int64
SentAt int64
Success bool
State AlertNotificationStateType
Version int64
UpdatedAt int64
AlertRuleStateUpdatedVersion int64
}
type RecordNotificationJournalCommand struct {
OrgId int64
AlertId int64
NotifierId int64
SentAt int64
Success bool
type SetAlertNotificationStateToPendingCommand struct {
Id int64
AlertRuleStateUpdatedVersion int64
Version int64
ResultVersion int64
}
type GetLatestNotificationQuery struct {
type SetAlertNotificationStateToCompleteCommand struct {
Id int64
Version int64
}
type GetOrCreateNotificationStateQuery struct {
OrgId int64
AlertId int64
NotifierId int64
Result []AlertNotificationJournal
}
type CleanNotificationJournalCommand struct {
OrgId int64
AlertId int64
NotifierId int64
Result *AlertNotificationState
}

View File

@@ -3,6 +3,8 @@ package alerting
import (
"context"
"time"
"github.com/grafana/grafana/pkg/models"
)
type EvalHandler interface {
@@ -20,7 +22,7 @@ type Notifier interface {
NeedsImage() bool
// 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
GetIsDefault() bool
@@ -28,11 +30,16 @@ type Notifier interface {
GetFrequency() time.Duration
}
type NotifierSlice []Notifier
type notifierState struct {
notifier Notifier
state *models.AlertNotificationState
}
func (notifiers NotifierSlice) ShouldUploadImage() bool {
for _, notifier := range notifiers {
if notifier.NeedsImage() {
type notifierStateSlice []*notifierState
func (notifiers notifierStateSlice) ShouldUploadImage() bool {
for _, ns := range notifiers {
if ns.notifier.NeedsImage() {
return true
}
}

View File

@@ -1,10 +1,8 @@
package alerting
import (
"context"
"errors"
"fmt"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/imguploader"
@@ -41,61 +39,78 @@ type notificationService struct {
}
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 {
return err
}
if len(notifiers) == 0 {
if len(notifierStates) == 0 {
return nil
}
if notifiers.ShouldUploadImage() {
if notifierStates.ShouldUploadImage() {
if err = n.uploadImage(context); err != nil {
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 {
for _, notifier := range notifiers {
not := notifier
func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
notifier := notifierState.notifier
err := bus.InTransaction(evalContext.Ctx, func(ctx context.Context) error {
n.log.Debug("trying to send notification", "id", not.GetNotifierId())
n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault())
metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
// Verify that we can send the notification again
// but this time within the same transaction.
if !evalContext.IsTestRun && !not.ShouldNotify(ctx, evalContext) {
return nil
err := notifier.Notify(evalContext)
if err != nil {
n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
}
n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
//send notification
success := not.Notify(evalContext) == nil
if evalContext.IsTestRun {
return nil
}
//write result to db.
cmd := &m.RecordNotificationJournalCommand{
OrgId: evalContext.Rule.OrgId,
AlertId: evalContext.Rule.Id,
NotifierId: not.GetNotifierId(),
SentAt: time.Now().Unix(),
Success: success,
cmd := &m.SetAlertNotificationStateToCompleteCommand{
Id: notifierState.state.Id,
Version: notifierState.state.Version,
}
return bus.DispatchCtx(ctx, cmd)
})
return bus.DispatchCtx(evalContext.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 {
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
}
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}
if err := bus.Dispatch(query); err != nil {
return nil, err
}
var result []Notifier
var result notifierStateSlice
for _, notification := range query.Result {
not, err := n.createNotifierFor(notification)
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) {
result = append(result, not)
query := &m.GetOrCreateNotificationStateQuery{
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
}
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)
// Do not notify when we become OK for the first time.

View File

@@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"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.
if context.PrevAlertState == context.Rule.State && !sendReminder {
if context.PrevAlertState == context.Rule.State && !n.SendReminder {
return false
}
// get last successfully sent notification
lastNotify := time.Time{}
for _, j := range journals {
if j.Success {
lastNotify = time.Unix(j.SentAt, 0)
break
}
}
if context.PrevAlertState == context.Rule.State && n.SendReminder {
// Do not notify if interval has not elapsed
if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
lastNotify := time.Unix(notiferState.UpdatedAt, 0)
if notiferState.UpdatedAt != 0 && lastNotify.Add(n.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) {
// 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 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
}
// 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
}
// 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 {
return n.Type
}

View File

@@ -2,12 +2,9 @@ package notifiers
import (
"context"
"errors"
"testing"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
@@ -23,34 +20,34 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState m.AlertStateType
sendReminder bool
frequency time.Duration
journals []m.AlertNotificationJournal
state *m.AlertNotificationState
expect bool
}{
{
name: "pending -> ok should not trigger an notification",
newState: m.AlertStatePending,
prevState: m.AlertStateOK,
newState: m.AlertStateOK,
prevState: m.AlertStatePending,
sendReminder: false,
journals: []m.AlertNotificationJournal{},
state: &m.AlertNotificationState{},
expect: false,
},
{
name: "ok -> alerting should trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
newState: m.AlertStateAlerting,
prevState: m.AlertStateOK,
sendReminder: false,
journals: []m.AlertNotificationJournal{},
state: &m.AlertNotificationState{},
expect: true,
},
{
name: "ok -> pending should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStatePending,
newState: m.AlertStatePending,
prevState: m.AlertStateOK,
sendReminder: false,
journals: []m.AlertNotificationJournal{},
state: &m.AlertNotificationState{},
expect: false,
},
@@ -59,100 +56,100 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: false,
journals: []m.AlertNotificationJournal{},
state: &m.AlertNotificationState{},
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",
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: true,
journals: []m.AlertNotificationJournal{},
state: &m.AlertNotificationState{},
expect: false,
},
{
name: "alerting -> alerting with reminder and no journaling should trigger",
newState: m.AlertStateAlerting,
name: "alerting -> ok should trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
journals: []m.AlertNotificationJournal{},
sendReminder: false,
state: &m.AlertNotificationState{},
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,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
journals: []m.AlertNotificationJournal{
{SentAt: tnow.Add(-time.Minute).Unix(), Success: true},
state: &m.AlertNotificationState{},
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,
},
{
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,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
expect: true,
journals: []m.AlertNotificationJournal{
{SentAt: tnow.Add(-time.Minute).Unix(), Success: false}, // recent failed notification
{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 {
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: tc.newState,
State: tc.prevState,
})
evalContext.Rule.State = tc.prevState
if defaultShouldNotify(evalContext, true, tc.frequency, tc.journals) != tc.expect {
evalContext.Rule.State = tc.newState
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)
}
}
}
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) {
Convey("default constructor for notifiers", t, func() {
bJson := simplejson.New()

View File

@@ -1,6 +1,7 @@
package notifiers
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -52,7 +53,8 @@ func TestTelegramNotifier(t *testing.T) {
})
Convey("generateCaption should generate a message with all pertinent details", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
Message: "Some kind of message.",
State: m.AlertStateOK,
@@ -68,7 +70,8 @@ func TestTelegramNotifier(t *testing.T) {
Convey("When generating a message", func() {
Convey("URL should be skipped if it's too long", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
Message: "Some kind of message.",
State: m.AlertStateOK,
@@ -85,7 +88,8 @@ func TestTelegramNotifier(t *testing.T) {
})
Convey("Message should be trimmed if it's too long", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
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,
@@ -101,7 +105,8 @@ func TestTelegramNotifier(t *testing.T) {
})
Convey("Metrics should be skipped if they don't fit", func() {
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
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,

View File

@@ -67,6 +67,12 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
}
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
@@ -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)
return nil
}

View File

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

View File

@@ -39,7 +39,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
return err
}
return notifier.sendNotifications(createTestEvalContext(cmd), []Notifier{notifiers})
return notifier.sendNotifications(createTestEvalContext(cmd), notifierStateSlice{{notifier: notifiers}})
}
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}
err := bus.Dispatch(cmd)
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
}
if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil {
return err
}
return nil
}
@@ -275,6 +279,8 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
}
sess.ID(alert.Id).Update(&alert)
cmd.Result = alert
return nil
})
}

View File

@@ -3,6 +3,7 @@ package sqlstore
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
@@ -18,16 +19,23 @@ func init() {
bus.AddHandler("sql", DeleteAlertNotification)
bus.AddHandler("sql", GetAlertNotificationsToSend)
bus.AddHandler("sql", GetAllAlertNotifications)
bus.AddHandlerCtx("sql", RecordNotificationJournal)
bus.AddHandlerCtx("sql", GetLatestNotification)
bus.AddHandlerCtx("sql", CleanNotificationJournal)
bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
}
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) error {
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
}
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 {
return withDbSession(ctx, func(sess *DBSession) error {
journalEntry := &m.AlertNotificationJournal{
OrgId: cmd.OrgId,
AlertId: cmd.AlertId,
NotifierId: cmd.NotifierId,
SentAt: cmd.SentAt,
Success: cmd.Success,
}
func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
version := cmd.Version
var current m.AlertNotificationState
sess.ID(cmd.Id).Get(&current)
_, err := sess.Insert(journalEntry)
return err
})
}
newVersion := cmd.Version + 1
func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error {
return withDbSession(ctx, func(sess *DBSession) error {
nj := []m.AlertNotificationJournal{}
sql := `UPDATE alert_notification_state SET
state = ?,
version = ?,
updated_at = ?
WHERE
id = ?`
err := sess.Desc("alert_notification_journal.sent_at").
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)
_, err := sess.Exec(sql, m.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.Id)
if err != nil {
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
})
}
func CleanNotificationJournal(ctx context.Context, cmd *m.CleanNotificationJournalCommand) error {
return inTransactionCtx(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 = ?"
_, err := sess.Exec(sql, cmd.OrgId, cmd.AlertId, cmd.NotifierId)
func SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToPendingCommand) error {
return withDbSession(ctx, func(sess *DBSession) error {
newVersion := cmd.Version + 1
sql := `UPDATE alert_notification_state SET
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"
"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"
)
@@ -14,58 +14,133 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Testing Alert notification sql access", t, func() {
InitTestDB(t)
Convey("Alert notification journal", func() {
var alertId int64 = 7
var orgId int64 = 5
var notifierId int64 = 10
Convey("Alert notification state", func() {
var alertID int64 = 7
var orgID int64 = 5
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() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
GetLatestNotification(context.Background(), query)
So(len(query.Result), ShouldEqual, 0)
Convey("Get no existing state should create a new state", func() {
query := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err := GetOrCreateAlertNotificationState(context.Background(), query)
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())
// 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)
Convey("Get existing state should not create a new state", func() {
query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err := GetOrCreateAlertNotificationState(context.Background(), query2)
So(err, ShouldBeNil)
So(query2.Result, ShouldNotBeNil)
So(query2.Result.Id, ShouldEqual, query.Result.Id)
So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
})
Convey("Update existing state to pending with correct version should update database", func() {
s := *query.Result
cmd := models.SetAlertNotificationStateToPendingCommand{
Id: s.Id,
Version: s.Version,
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
}
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldBeNil)
So(cmd.ResultVersion, ShouldEqual, 1)
query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
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)
Convey("should be able to record two journaling events", func() {
createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1}
query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err = GetOrCreateAlertNotificationState(context.Background(), query3)
So(err, ShouldBeNil)
So(query3.Result.Version, ShouldEqual, 2)
So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
})
err := RecordNotificationJournal(context.Background(), createCmd)
Convey("Update existing state to completed should update database. regardless of version", func() {
s := *query.Result
unknownVersion := int64(1000)
cmd := models.SetAlertNotificationStateToCompleteCommand{
Id: s.Id,
Version: unknownVersion,
}
err := SetAlertNotificationStateToCompleteCommand(context.Background(), &cmd)
So(err, ShouldBeNil)
createCmd.SentAt += 1000 //increase epoch
err = RecordNotificationJournal(context.Background(), createCmd)
query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
err = GetOrCreateAlertNotificationState(context.Background(), query3)
So(err, ShouldBeNil)
Convey("get last journaling event", func() {
err := GetLatestNotification(context.Background(), query)
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() {
cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId}
err := CleanNotificationJournal(context.Background(), cmd)
So(err, ShouldBeNil)
Convey("querying for last journaling should return no journal entries", func() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
err := GetLatestNotification(context.Background(), query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 0)
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() {
cmd := &m.GetAlertNotificationsQuery{
cmd := &models.GetAlertNotificationsQuery{
OrgId: 2,
Name: "email",
}
@@ -76,7 +151,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Cannot save alert notifier with send reminder = true", func() {
cmd := &m.CreateAlertNotificationCommand{
cmd := &models.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
@@ -86,7 +161,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("and missing frequency", func() {
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
@@ -98,7 +173,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Cannot update alert notifier with send reminder = false", func() {
cmd := &m.CreateAlertNotificationCommand{
cmd := &models.CreateAlertNotificationCommand{
Name: "ops update",
Type: "email",
OrgId: 1,
@@ -109,14 +184,14 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
updateCmd := &m.UpdateAlertNotificationCommand{
updateCmd := &models.UpdateAlertNotificationCommand{
Id: cmd.Result.Id,
SendReminder: true,
}
Convey("and missing frequency", func() {
err := UpdateAlertNotification(updateCmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
@@ -129,7 +204,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can save Alert Notification", func() {
cmd := &m.CreateAlertNotificationCommand{
cmd := &models.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
@@ -151,7 +226,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can update alert notification", func() {
newCmd := &m.UpdateAlertNotificationCommand{
newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
@@ -167,7 +242,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can update alert notification to disable sending of reminders", func() {
newCmd := &m.UpdateAlertNotificationCommand{
newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
@@ -182,12 +257,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
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()}
cmd2 := m.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()}
cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", 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 := models.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", 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 := 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(&cmd2), ShouldBeNil)
@@ -196,7 +271,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
Convey("search", func() {
query := &m.GetAlertNotificationsToSendQuery{
query := &models.GetAlertNotificationsToSendQuery{
Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
OrgId: 1,
}
@@ -207,7 +282,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("all", func() {
query := &m.GetAllAlertNotificationsQuery{
query := &models.GetAllAlertNotificationsQuery{
OrgId: 1,
}

View File

@@ -107,4 +107,27 @@ func addAlertMigrations(mg *Migrator) {
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("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
NoOpSql() string
IsUniqueConstraintViolation(err error) bool
}
func NewDialect(engine *xorm.Engine) Dialect {

View File

@@ -5,6 +5,8 @@ import (
"strconv"
"strings"
"github.com/VividCortex/mysqlerr"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
)
@@ -125,3 +127,13 @@ func (db *Mysql) CleanDB() error {
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"
"github.com/go-xorm/xorm"
"github.com/lib/pq"
)
type Postgres struct {
@@ -136,3 +137,13 @@ func (db *Postgres) CleanDB() error {
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"
"github.com/go-xorm/xorm"
sqlite3 "github.com/mattn/go-sqlite3"
)
type Sqlite3 struct {
@@ -82,3 +83,13 @@ func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
func (db *Sqlite3) CleanDB() error {
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{})),
}
_, err := NewClient(nil, ds, nil)
_, err := NewClient(context.Background(), ds, nil)
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)
})
@@ -48,7 +48,7 @@ func TestClient(t *testing.T) {
}),
}
_, err := NewClient(nil, ds, nil)
_, err := NewClient(context.Background(), ds, nil)
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(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(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(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
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":
if len(args) < 2 {
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.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":
if len(args) < 2 {
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())
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
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()))
})
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() {
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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil)
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() {
@@ -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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil)
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
import (
"context"
"fmt"
"math/rand"
"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"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -675,6 +676,30 @@ func TestMSSQL(t *testing.T) {
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() {
sql := `
IF object_id('sp_test_epoch') IS NOT NULL
@@ -719,9 +744,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE
@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"]
So(err, ShouldBeNil)
So(queryResult.Error, ShouldBeNil)
@@ -796,9 +823,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() {
tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE
@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"]
So(err, 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"]
So(err, ShouldBeNil)
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"]
So(err, ShouldBeNil)
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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
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":
if len(args) < 2 {
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.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":
if len(args) < 2 {
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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
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()))
})
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() {
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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
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() {
@@ -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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
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
import (
"context"
"fmt"
"math/rand"
"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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -313,7 +314,7 @@ func TestMySQL(t *testing.T) {
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
DataSource: &models.DataSource{},
DataSource: &models.DataSource{JsonData: simplejson.New()},
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",
"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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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() {
type event struct {
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"]
So(err, ShouldBeNil)
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"]
So(err, ShouldBeNil)
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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
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":
if len(args) < 2 {
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.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":
if len(args) < 2 {
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)))
})
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() {
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)")
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
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()))
})
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() {
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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
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() {
@@ -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)))
})
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() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
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
import (
"context"
"fmt"
"math/rand"
"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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -683,6 +684,30 @@ func TestPostgres(t *testing.T) {
So(queryResult.Series[0].Name, ShouldEqual, "valueOne")
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() {
@@ -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"]
So(err, ShouldBeNil)
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"]
So(err, ShouldBeNil)
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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)
queryResult := resp.Results["A"]
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", 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
}

View File

@@ -1,6 +1,7 @@
package tsdb
import (
"fmt"
"testing"
"time"
@@ -43,6 +44,34 @@ func TestSqlEngine(t *testing.T) {
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() {

View File

@@ -95,27 +95,20 @@ func init() {
Id: "random_walk",
Name: "Random Walk",
Handler: func(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
to := tsdbQuery.TimeRange.GetToAsMsEpoch()
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
return getRandomWalk(query, context)
},
})
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
registerScenario(&Scenario{
Id: "slow_query",
Name: "Slow Query",
StringInput: "5s",
Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
stringInput := query.Model.Get("stringInput").MustString()
parsedInterval, _ := time.ParseDuration(stringInput)
time.Sleep(parsedInterval)
return getRandomWalk(query, context)
},
})
@@ -221,6 +214,57 @@ func init() {
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) {

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

View File

@@ -10,6 +10,7 @@ import colors from 'app/core/utils/colors';
import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { configureStore } from 'app/store/configureStore';
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
export class GrafanaCtrl {
/** @ngInject */
@@ -22,11 +23,13 @@ export class GrafanaCtrl {
contextSrv,
bridgeSrv,
backendSrv: BackendSrv,
datasourceSrv: DatasourceSrv
datasourceSrv: DatasourceSrv,
angularLoader: AngularLoader
) {
// sets singleston instances for angular services so react components can access them
configureStore();
setAngularLoader(angularLoader);
setBackendSrv(backendSrv);
configureStore();
$scope.init = () => {
$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 coreModule from 'app/core/core_module';
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-global-bind';
@@ -15,7 +15,14 @@ export class KeybindingSrv {
timepickerOpen = false;
/** @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
$rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset();
@@ -194,14 +201,9 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource);
if (datasource && datasource.supportsExplore) {
const range = this.timeSrv.timeRangeForUrl();
const state = {
...datasource.getExploreState(panel),
range,
};
const exploreState = JSON.stringify(state);
this.$location.url(renderUrl('/explore', { state: exploreState }));
const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
if (url) {
this.$timeout(() => this.$location.url(url));
}
}
});

View File

@@ -1,6 +1,5 @@
import { serializeStateToUrlParam, parseUrlState } from './Wrapper';
import { DEFAULT_RANGE } from './TimePicker';
import { ExploreState } from './Explore';
import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore';
import { ExploreState } from 'app/types/explore';
const DEFAULT_EXPLORE_STATE: ExploreState = {
datasource: null,
@@ -8,6 +7,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
datasourceLoading: null,
datasourceMissing: false,
datasourceName: '',
exploreDatasources: [],
graphResult: null,
history: [],
latency: 0,
@@ -27,7 +27,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
tableResult: null,
};
describe('Wrapper state functions', () => {
describe('state functions', () => {
describe('parseUrlState', () => {
it('returns default state on empty string', () => {
expect(parseUrlState('')).toMatchObject({

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';
// 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 => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;

View File

@@ -58,7 +58,7 @@ export function updateDashboardPermission(
continue;
}
const updated = toUpdateItem(itemToUpdate);
const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion
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(),
navModel: {} as NavModel,
dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
setDataSourcesLayoutMode: jest.fn(),
};
Object.assign(props, propOverrides);

View File

@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
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 { loadDataSources } from './state/actions';
import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
import { getNavModel } from '../../core/selectors/navModel';
import { DataSource, NavModel } from 'app/types';
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 {
navModel: NavModel;
dataSources: DataSource[];
dataSourcesCount: number;
layoutMode: LayoutMode;
searchQuery: string;
loadDataSources: typeof loadDataSources;
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
}
const emptyListModel = {
@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
}
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 (
<div>
@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
<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" />,
]
)}
@@ -66,11 +94,14 @@ function mapStateToProps(state) {
dataSources: getDataSources(state.dataSources),
layoutMode: getDataSourcesLayoutMode(state.dataSources),
dataSourcesCount: getDataSourcesCount(state.dataSources),
searchQuery: getDataSourcesSearchQuery(state.dataSources),
};
}
const mapDispatchToProps = {
loadDataSources,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
};
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
className="page-container page-body"
>
<Connect(DataSourcesActionBar)
<OrgActionBar
key="action-bar"
layoutMode="grid"
linkButton={
Object {
"href": "datasources/new",
"title": "Add data source",
}
}
onSetLayoutMode={[Function]}
searchQuery=""
setSearchQuery={[Function]}
/>
<DataSourcesList
dataSources={

View File

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

View File

@@ -156,6 +156,7 @@ interface PromQueryFieldState {
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
metrics: string[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[];
}
@@ -167,7 +168,7 @@ interface PromTypeaheadInput {
value?: Value;
}
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
constructor(props: PromQueryFieldProps, context) {
@@ -189,6 +190,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
metricsOptions: [],
};
}
@@ -258,10 +260,22 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
};
onReceiveMetrics = () => {
if (!this.state.metrics) {
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
if (!metrics) {
return;
}
// Update global prism config
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 => {
@@ -453,7 +467,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
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() {
const { error, hint, supportsLogs } = this.props;
const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state;
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
const { logLabelOptions, metricsOptions } = this.state;
return (
<div className="prom-query-field">
@@ -575,6 +584,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
portalPrefix="prometheus"
/>
</div>
{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 { makeFragment, makeValue } from './Value';
export const TYPEAHEAD_DEBOUNCE = 300;
export const TYPEAHEAD_DEBOUNCE = 100;
function flattenSuggestions(s: any[]): any[] {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
// 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 {
@@ -125,7 +132,7 @@ export interface TypeaheadOutput {
suggestions: SuggestionGroup[];
}
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null;
plugins: any[];
resetTimer: any;
@@ -154,9 +161,15 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
clearTimeout(this.resetTimer);
}
componentDidUpdate() {
componentDidUpdate(prevProps, prevState) {
// 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) {
// initialValue is null in case the user typed
@@ -166,15 +179,21 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
}
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 }, () => {
if (changed) {
if (textChanged) {
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);
} else {
this.resetTypeahead();
}
};
@@ -216,7 +235,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
wrapperNode,
});
const filteredSuggestions = suggestions
let filteredSuggestions = suggestions
.map(group => {
if (group.items) {
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
// Keep same object for equality checking later
if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
filteredSuggestions = this.state.suggestions;
}
this.setState(
{
suggestions: filteredSuggestions,
@@ -326,12 +350,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
return undefined;
}
// Get the currently selected suggestion
const flattenedSuggestions = flattenSuggestions(suggestions);
const selected = Math.abs(typeaheadIndex);
const selectedIndex = selected % flattenedSuggestions.length || 0;
const suggestion = flattenedSuggestions[selectedIndex];
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
this.applyTypeahead(change, suggestion);
return true;
}
@@ -408,8 +427,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
}
// No suggestions or blur, remove menu
const hasSuggesstions = suggestions && suggestions.length > 0;
if (!hasSuggesstions) {
if (!hasSuggestions(suggestions)) {
menu.removeAttribute('style');
return;
}
@@ -436,18 +454,12 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
renderMenu = () => {
const { portalPrefix } = this.props;
const { suggestions } = this.state;
const hasSuggesstions = suggestions && suggestions.length > 0;
if (!hasSuggesstions) {
const { suggestions, typeaheadIndex } = this.state;
if (!hasSuggestions(suggestions)) {
return null;
}
// Guard selectedIndex to be within the length of the suggestions
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;
const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
// Create typeahead in DOM root so we can later position it absolutely
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;
constructor(props) {

View File

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

View File

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

View File

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

View File

@@ -3,31 +3,11 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { updateLocation } from 'app/core/actions';
import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
import { StoreState } from 'app/types';
import { ExploreUrlState } from 'app/types/explore';
import { ExploreState } from 'app/types/explore';
import Explore, { ExploreState } 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);
}
import Explore from './Explore';
interface WrapperProps {
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}`;
}
export function ensureQueries(queries?) {
export function ensureQueries(queries?: Query[]): Query[] {
if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') {
return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query }));
}
return [{ key: generateQueryKey(), query: '' }];
}
export function hasQuery(queries) {
return queries.some(q => q.query);
export function hasQuery(queries: string[]): boolean {
return queries.some(q => Boolean(q));
}

View File

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

View File

@@ -1,6 +1,4 @@
import './org_users_ctrl';
import './profile_ctrl';
import './org_users_ctrl';
import './select_org_ctrl';
import './change_password_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 * as rangeUtil from 'app/core/utils/rangeutil';
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';
@@ -314,7 +314,12 @@ class MetricsPanelCtrl extends PanelCtrl {
getAdditionalMenuItems() {
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({
text: 'Explore',
click: 'ctrl.explore();',
@@ -325,14 +330,11 @@ class MetricsPanelCtrl extends PanelCtrl {
return items;
}
explore() {
const range = this.timeSrv.timeRangeForUrl();
const state = {
...this.datasource.getExploreState(this.panel),
range,
};
const exploreState = JSON.stringify(state);
this.$location.url(renderUrl('/explore', { state: exploreState }));
async explore() {
const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
if (url) {
this.$timeout(() => this.$location.url(url));
}
}
addQuery(target) {

View File

@@ -38,7 +38,7 @@ describe('MetricsPanelCtrl', () => {
describe('and has datasource set that supports explore and user has powers', () => {
beforeEach(() => {
ctrl.contextSrv = { isEditor: true };
ctrl.datasource = { supportsExplore: true };
ctrl.datasource = { meta: { explore: true } };
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 = {
navModel: {} as NavModel,
plugins: [] as Plugin[],
searchQuery: '',
setPluginsSearchQuery: jest.fn(),
setPluginsLayoutMode: jest.fn(),
layoutMode: LayoutModes.Grid,
loadPlugins: jest.fn(),
};

View File

@@ -1,20 +1,23 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import PageHeader from '../../core/components/PageHeader/PageHeader';
import PluginActionBar from './PluginActionBar';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
import PluginList from './PluginList';
import { NavModel, Plugin } from '../../types';
import { loadPlugins } from './state/actions';
import { NavModel, Plugin } from 'app/types';
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
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';
export interface Props {
navModel: NavModel;
plugins: Plugin[];
layoutMode: LayoutMode;
searchQuery: string;
loadPlugins: typeof loadPlugins;
setPluginsLayoutMode: typeof setPluginsLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
}
export class PluginListPage extends PureComponent<Props> {
@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
}
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 (
<div>
<PageHeader model={navModel} />
<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} />}
</div>
</div>
@@ -46,11 +59,14 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'plugins'),
plugins: getPlugins(state.plugins),
layoutMode: getLayoutMode(state.plugins),
searchQuery: getPluginsSearchQuery(state.plugins),
};
}
const mapDispatchToProps = {
loadPlugins,
setPluginsLayoutMode,
setPluginsSearchQuery,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

View File

@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
<div
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
layoutMode="grid"
plugins={Array []}

View File

@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
payload: LayoutMode;
}
export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
type: ActionTypes.SetLayoutMode,
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 {
type: any;
name: any;
supportMetrics: any;
proxyUrl: any;
defaultRegion: any;
instanceSettings: any;
@@ -17,7 +16,6 @@ export default class CloudWatchDatasource {
constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
this.type = 'cloudwatch';
this.name = instanceSettings.name;
this.supportMetrics = true;
this.proxyUrl = instanceSettings.url;
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
this.instanceSettings = instanceSettings;

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