Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69b4bf8125 | |||
| 62f85c3772 | |||
| 2e0165e80a | |||
| 272840e0cb | |||
| 694c738b6d | |||
| a493a773a2 | |||
| 9af809ff8a | |||
| 321c09aec4 | |||
| 0e0901874b | |||
| 418f77e70e | |||
| 7b781e4c3c | |||
| 9289cba625 | |||
| 67e5f62514 | |||
| 9d3659d30d | |||
| b45a1bd45c | |||
| 9022e871e3 | |||
| 341d8af637 | |||
| 7f1d7cefc0 | |||
| 9e09b2b969 | |||
| f37a60dcd5 | |||
| 8fd1d8a057 | |||
| 8e9c0a44c3 | |||
| 35eddee2dd | |||
| 505ae709c9 | |||
| 12a99fa0b5 | |||
| 4ce8cc77e4 | |||
| 6c190ab713 | |||
| 236e0b3814 | |||
| ec4139d56b | |||
| dde650905d | |||
| 043d5f1c05 | |||
| a4f5aafe22 | |||
| 75f832cda8 | |||
| b29ac5c509 | |||
| 28a9caa34d | |||
| 68dfc5699b | |||
| 54c9beb146 | |||
| 1be8fb76b8 | |||
| 5ec086dc56 | |||
| d412aafb7e | |||
| d313ffa847 | |||
| 8551ffa0b0 | |||
| a0e1a1a1f9 | |||
| 67c58fa5e4 | |||
| c1763508e0 | |||
| 2bf399d3c8 | |||
| 7ae4076ddd | |||
| 69cc24ea3f | |||
| 88bbc452a7 | |||
| 166f93cf54 | |||
| 1a75aa54de | |||
| 15ce474639 | |||
| d093244282 | |||
| c5278af6c4 | |||
| 3fab616239 | |||
| ff79f80685 | |||
| 90595ffdce |
@@ -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
|
||||
|
||||
@@ -73,3 +73,5 @@ debug.test
|
||||
|
||||
/devenv/bulk-dashboards/*.json
|
||||
/devenv/bulk_alerting_dashboards/*.json
|
||||
|
||||
/scripts/build/release_publisher/release_publisher
|
||||
|
||||
+6
-6
@@ -1,8 +1,11 @@
|
||||
# 5.4.0 (unreleased)
|
||||
# 5.3.0 (unreleased)
|
||||
|
||||
# 5.3.0-beta2 (2018-10-01)
|
||||
|
||||
### New Features
|
||||
|
||||
* **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735)
|
||||
* **Stackdriver**: Support for Google Stackdriver Datasource [#13289](https://github.com/grafana/grafana/pull/13289)
|
||||
|
||||
### Minor
|
||||
|
||||
@@ -15,12 +18,9 @@
|
||||
* **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon)
|
||||
* **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030)
|
||||
* **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173)
|
||||
|
||||
# 5.3.0 (unreleased)
|
||||
|
||||
### Minor
|
||||
|
||||
* **Alerting**: Link to view full size image in Microsoft Teams alert notifier [#13121](https://github.com/grafana/grafana/issues/13121), thx [@holiiveira](https://github.com/holiiveira)
|
||||
* **Alerting**: Fixes a bug where all alerts would send reminders after upgrade & restart [#13402](https://github.com/grafana/grafana/pull/13402)
|
||||
* **Alerting**: Concurrent render limit for graphs used in notifications [#13401](https://github.com/grafana/grafana/pull/13401)
|
||||
* **Postgres/MySQL/MSSQL**: Add support for replacing $__interval and $__interval_ms in alert queries [#11555](https://github.com/grafana/grafana/issues/11555), thx [@svenklemm](https://github.com/svenklemm)
|
||||
|
||||
# 5.3.0-beta1 (2018-09-06)
|
||||
|
||||
Generated
+7
-1
@@ -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
|
||||
|
||||
@@ -203,3 +203,7 @@ ignored = [
|
||||
[[constraint]]
|
||||
name = "github.com/denisenkom/go-mssqldb"
|
||||
revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/VividCortex/mysqlerr"
|
||||
branch = "master"
|
||||
|
||||
+1
-1
@@ -435,7 +435,7 @@ log_queries =
|
||||
;sampler_param = 1
|
||||
|
||||
#################################### Grafana.com integration ##########################
|
||||
# Url used to to import dashboards directly from Grafana.com
|
||||
# Url used to import dashboards directly from Grafana.com
|
||||
[grafana_com]
|
||||
;url = https://grafana.com
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -54,4 +75,4 @@ services:
|
||||
# environment:
|
||||
# - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
|
||||
# ports:
|
||||
# - 9104
|
||||
# - 9104
|
||||
|
||||
@@ -181,6 +181,7 @@ group_search_filter = "(member:1.2.840.113556.1.4.1941:=CN=%s,[user container/OU
|
||||
group_search_filter = "(|(member:1.2.840.113556.1.4.1941:=CN=%s,[user container/OU])(member:1.2.840.113556.1.4.1941:=CN=%s,[another user container/OU]))"
|
||||
group_search_filter_user_attribute = "cn"
|
||||
```
|
||||
For more information on AD searches see [Microsoft's Search Filter Syntax](https://docs.microsoft.com/en-us/windows/desktop/adsi/search-filter-syntax) documentation.
|
||||
|
||||
For troubleshooting, by changing `member_of` in `[servers.attributes]` to "dn" it will show you more accurate group memberships when [debug is enabled](#troubleshooting).
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ weight = 11
|
||||
|
||||
# Using Google Stackdriver in Grafana
|
||||
|
||||
Grafana ships with built-in support for Google Stackdriver. Just add it as a datasource and you are ready to build dashboards for your Stackdriver metrics. It is only available in Grafana 5.3+. The datasource is currently a beta feature and is subject to change.
|
||||
> Only available in Grafana v5.3+.
|
||||
> The datasource is currently a beta feature and is subject to change.
|
||||
|
||||
Grafana ships with built-in support for Google Stackdriver. Just add it as a datasource and you are ready to build dashboards for your Stackdriver metrics.
|
||||
|
||||
## Adding the data source to Grafana
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "5.3.0-beta2",
|
||||
"version": "5.3.0-beta3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
|
||||
@@ -97,15 +97,6 @@ type CacheServer struct {
|
||||
cache *gocache.Cache
|
||||
}
|
||||
|
||||
func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) {
|
||||
for _, k := range keys {
|
||||
if _, err := fmt.Sscanf(r.FormValue(k), "%d", &v); err == nil {
|
||||
defaultValue = v
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func (this *CacheServer) Handler(ctx *macaron.Context) {
|
||||
urlPath := ctx.Req.URL.Path
|
||||
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
|
||||
|
||||
@@ -133,16 +133,6 @@ func TestFoldersApiEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func callGetFolderByUID(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetFolderByUID
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func callDeleteFolder(sc *scenarioContext) {
|
||||
sc.handlerFunc = DeleteFolder
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func callCreateFolder(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ func newHub() *hub {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) removeConnection() {
|
||||
}
|
||||
|
||||
func (h *hub) run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/codegangsta/cli"
|
||||
"github.com/fatih/color"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -24,6 +25,7 @@ func runDbCommand(command func(commandLine CommandLine) error) func(context *cli
|
||||
|
||||
engine := &sqlstore.SqlStore{}
|
||||
engine.Cfg = cfg
|
||||
engine.Bus = bus.GetBus()
|
||||
engine.Init()
|
||||
|
||||
if err := command(cmd); err != nil {
|
||||
|
||||
@@ -104,7 +104,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) {
|
||||
|
||||
for {
|
||||
select {
|
||||
case _ = <-sighupChan:
|
||||
case <-sighupChan:
|
||||
log.Reload()
|
||||
case sig := <-signalChan:
|
||||
server.Shutdown(fmt.Sprintf("System signal: %s", sig))
|
||||
|
||||
@@ -127,8 +127,6 @@ type xmlError struct {
|
||||
const ms_date_layout = "Mon, 02 Jan 2006 15:04:05 GMT"
|
||||
const version = "2017-04-17"
|
||||
|
||||
var client = &http.Client{}
|
||||
|
||||
type StorageClient struct {
|
||||
Auth *Auth
|
||||
Transport http.RoundTripper
|
||||
|
||||
@@ -435,11 +435,6 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) withInvalidApiKey() *scenarioContext {
|
||||
sc.apiKey = "nvalidhhhhds"
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
|
||||
sc.authHeader = authHeader
|
||||
return sc
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -8,8 +8,18 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
|
||||
ErrJournalingNotFound = errors.New("alert notification journaling not found")
|
||||
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
|
||||
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 {
|
||||
Id int64
|
||||
OrgId int64
|
||||
AlertId int64
|
||||
NotifierId int64
|
||||
SentAt int64
|
||||
Success bool
|
||||
type AlertNotificationState struct {
|
||||
Id int64
|
||||
OrgId int64
|
||||
AlertId int64
|
||||
NotifierId int64
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
|
||||
metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
|
||||
if err != nil {
|
||||
n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
|
||||
}
|
||||
|
||||
//send notification
|
||||
success := not.Notify(evalContext) == nil
|
||||
if evalContext.IsTestRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if evalContext.IsTestRun {
|
||||
return nil
|
||||
}
|
||||
cmd := &m.SetAlertNotificationStateToCompleteCommand{
|
||||
Id: notifierState.state.Id,
|
||||
Version: notifierState.state.Version,
|
||||
}
|
||||
|
||||
//write result to db.
|
||||
cmd := &m.RecordNotificationJournalCommand{
|
||||
OrgId: evalContext.Rule.OrgId,
|
||||
AlertId: evalContext.Rule.Id,
|
||||
NotifierId: not.GetNotifierId(),
|
||||
SentAt: time.Now().Unix(),
|
||||
Success: success,
|
||||
}
|
||||
return bus.DispatchCtx(evalContext.Ctx, cmd)
|
||||
}
|
||||
|
||||
return bus.DispatchCtx(ctx, cmd)
|
||||
})
|
||||
func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error {
|
||||
if !evalContext.IsTestRun {
|
||||
setPendingCmd := &m.SetAlertNotificationStateToPendingCommand{
|
||||
Id: notifierState.state.Id,
|
||||
Version: notifierState.state.Version,
|
||||
AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges,
|
||||
}
|
||||
|
||||
err := bus.DispatchCtx(evalContext.Ctx, setPendingCmd)
|
||||
if err == m.ErrAlertNotificationStateVersionConflict {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
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, ¬ifierState{
|
||||
notifier: not,
|
||||
state: query.Result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
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 is OK or pending even on repeated notify
|
||||
if context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Do not notify if interval has not elapsed
|
||||
if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify if alert state if OK or pending even on repeated notify
|
||||
if sendReminder && (context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify when we become OK for the first time.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
},
|
||||
state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,10 +37,6 @@ func NewTicker(last time.Time, initialOffset time.Duration, c clock.Clock) *Tick
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Ticker) updateOffset(offset time.Duration) {
|
||||
t.newOffset <- offset
|
||||
}
|
||||
|
||||
func (t *Ticker) run() {
|
||||
for {
|
||||
next := t.last.Add(time.Duration(1) * time.Second)
|
||||
|
||||
@@ -9,12 +9,6 @@ import (
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
type testTriggeredAlert struct {
|
||||
ActualValue float64
|
||||
Name string
|
||||
State string
|
||||
}
|
||||
|
||||
func TestNotifications(t *testing.T) {
|
||||
|
||||
Convey("Given the notifications service", t, func() {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
return err
|
||||
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(¤t)
|
||||
|
||||
_, 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)
|
||||
return err
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 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 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())
|
||||
|
||||
Convey("should be able to record two journaling events", func() {
|
||||
createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1}
|
||||
|
||||
err := RecordNotificationJournal(context.Background(), createCmd)
|
||||
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())
|
||||
})
|
||||
|
||||
createCmd.SentAt += 1000 //increase epoch
|
||||
Convey("Update existing state to pending with correct version should update database", func() {
|
||||
s := *query.Result
|
||||
|
||||
err = RecordNotificationJournal(context.Background(), createCmd)
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
So(err, ShouldBeNil)
|
||||
So(cmd.ResultVersion, ShouldEqual, 1)
|
||||
|
||||
Convey("get last journaling event", func() {
|
||||
err := GetLatestNotification(context.Background(), query)
|
||||
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)
|
||||
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)
|
||||
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())
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
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)
|
||||
|
||||
query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
|
||||
err = GetOrCreateAlertNotificationState(context.Background(), query3)
|
||||
So(err, ShouldBeNil)
|
||||
So(query3.Result.Version, ShouldEqual, unknownVersion+1)
|
||||
So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
|
||||
So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Update existing state to pending with incorrect version should return version mismatch error", func() {
|
||||
s := *query.Result
|
||||
s.Version = 1000
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.NotifierId,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
So(err, ShouldEqual, models.ErrAlertNotificationStateVersionConflict)
|
||||
})
|
||||
|
||||
Convey("Updating existing state to pending with incorrect version since alert rule state update version is higher", func() {
|
||||
s := *query.Result
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: 1000,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(cmd.ResultVersion, ShouldEqual, 1)
|
||||
})
|
||||
|
||||
Convey("different version and same alert state change version should return error", func() {
|
||||
s := *query.Result
|
||||
s.Version = 1000
|
||||
cmd := models.SetAlertNotificationStateToPendingCommand{
|
||||
Id: s.Id,
|
||||
Version: s.Version,
|
||||
AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
|
||||
}
|
||||
err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
timeNow = oldTimeNow
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Alert notifications should be empty", func() {
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -932,29 +932,6 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
dashboardGuardianMock *guardian.FakeDashboardGuardian
|
||||
}
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
|
||||
func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
origNewDashboardGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(mock)
|
||||
|
||||
sc := &scenarioContext{
|
||||
dashboardGuardianMock: mock,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
guardian.New = origNewDashboardGuardian
|
||||
}()
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
type dashboardPermissionScenarioContext struct {
|
||||
dashboardGuardianMock *guardian.FakeDashboardGuardian
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ type Dialect interface {
|
||||
|
||||
CleanDB() error
|
||||
NoOpSql() string
|
||||
|
||||
IsUniqueConstraintViolation(err error) bool
|
||||
}
|
||||
|
||||
func NewDialect(engine *xorm.Engine) Dialect {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ import (
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
type testQuery struct {
|
||||
result bool
|
||||
}
|
||||
|
||||
var ProvokedError = errors.New("testing error.")
|
||||
|
||||
func TestTransaction(t *testing.T) {
|
||||
|
||||
@@ -42,8 +42,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
|
||||
accessKeyId := ""
|
||||
secretAccessKey := ""
|
||||
sessionToken := ""
|
||||
var expiration *time.Time
|
||||
expiration = nil
|
||||
var expiration *time.Time = nil
|
||||
if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 {
|
||||
params := &sts.AssumeRoleInput{
|
||||
RoleArn: aws.String(dsInfo.AssumeRoleArn),
|
||||
|
||||
@@ -353,8 +353,5 @@ func loadTestFile(path string) (StackdriverResponse, error) {
|
||||
return data, err
|
||||
}
|
||||
err = json.Unmarshal(jsonBody, &data)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
return data, nil
|
||||
return data, err
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+4
-5
@@ -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,
|
||||
@@ -27,7 +26,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({
|
||||
@@ -57,7 +56,7 @@ describe('Wrapper state functions', () => {
|
||||
};
|
||||
expect(serializeStateToUrlParam(state)).toBe(
|
||||
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
const setup = () => {
|
||||
const props = {
|
||||
dataSources: getMockDataSources(3),
|
||||
layoutMode: LayoutModes.Grid,
|
||||
};
|
||||
|
||||
return shallow(<DataSourcesList {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames/bind';
|
||||
import DataSourcesListItem from './DataSourcesListItem';
|
||||
import { DataSource } from 'app/types';
|
||||
import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
export interface Props {
|
||||
dataSources: DataSource[];
|
||||
layoutMode: LayoutMode;
|
||||
}
|
||||
|
||||
export class DataSourcesList extends PureComponent<Props> {
|
||||
render() {
|
||||
const { dataSources, layoutMode } = this.props;
|
||||
|
||||
const listStyle = classNames({
|
||||
'card-section': true,
|
||||
'card-list-layout-grid': layoutMode === LayoutModes.Grid,
|
||||
'card-list-layout-list': layoutMode === LayoutModes.List,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={listStyle}>
|
||||
<ol className="card-list">
|
||||
{dataSources.map((dataSource, index) => {
|
||||
return <DataSourcesListItem dataSource={dataSource} key={`${dataSource.id}-${index}`} />;
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataSourcesList;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import DataSourcesListItem from './DataSourcesListItem';
|
||||
import { getMockDataSource } from './__mocks__/dataSourcesMocks';
|
||||
|
||||
const setup = () => {
|
||||
const props = {
|
||||
dataSource: getMockDataSource(),
|
||||
};
|
||||
|
||||
return shallow(<DataSourcesListItem {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { DataSource } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
dataSource: DataSource;
|
||||
}
|
||||
|
||||
export class DataSourcesListItem extends PureComponent<Props> {
|
||||
render() {
|
||||
const { dataSource } = this.props;
|
||||
return (
|
||||
<li className="card-item-wrapper">
|
||||
<a className="card-item" href={`datasources/edit/${dataSource.id}`}>
|
||||
<div className="card-item-header">
|
||||
<div className="card-item-type">{dataSource.type}</div>
|
||||
</div>
|
||||
<div className="card-item-body">
|
||||
<figure className="card-item-figure">
|
||||
<img src={dataSource.typeLogoUrl} />
|
||||
</figure>
|
||||
<div className="card-item-details">
|
||||
<div className="card-item-name">
|
||||
{dataSource.name}
|
||||
{dataSource.isDefault && <span className="btn btn-secondary btn-mini">default</span>}
|
||||
</div>
|
||||
<div className="card-item-sub-name">{dataSource.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataSourcesListItem;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataSourcesListPage, Props } from './DataSourcesListPage';
|
||||
import { DataSource, NavModel } from 'app/types';
|
||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
dataSources: [] as DataSource[],
|
||||
layoutMode: LayoutModes.Grid,
|
||||
loadDataSources: jest.fn(),
|
||||
navModel: {} as NavModel,
|
||||
dataSourcesCount: 0,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<DataSourcesListPage {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render action bar and datasources', () => {
|
||||
const wrapper = setup({
|
||||
dataSources: getMockDataSources(5),
|
||||
dataSourcesCount: 5,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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 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';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
dataSources: DataSource[];
|
||||
dataSourcesCount: number;
|
||||
layoutMode: LayoutMode;
|
||||
loadDataSources: typeof loadDataSources;
|
||||
}
|
||||
|
||||
const emptyListModel = {
|
||||
title: 'There are no data sources defined yet',
|
||||
buttonIcon: 'gicon gicon-add-datasources',
|
||||
buttonLink: 'datasources/new',
|
||||
buttonTitle: 'Add data source',
|
||||
proTip: 'You can also define data sources through configuration files.',
|
||||
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
|
||||
proTipLinkTitle: 'Learn more',
|
||||
proTipTarget: '_blank',
|
||||
};
|
||||
|
||||
export class DataSourcesListPage extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.fetchDataSources();
|
||||
}
|
||||
|
||||
async fetchDataSources() {
|
||||
return await this.props.loadDataSources();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
{dataSourcesCount === 0 ? (
|
||||
<EmptyListCTA model={emptyListModel} />
|
||||
) : (
|
||||
[
|
||||
<DataSourcesActionBar key="action-bar" />,
|
||||
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
||||
]
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, 'datasources'),
|
||||
dataSources: getDataSources(state.dataSources),
|
||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadDataSources,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DataSource } from 'app/types';
|
||||
|
||||
export const getMockDataSources = (amount: number): DataSource[] => {
|
||||
const dataSources = [];
|
||||
|
||||
for (let i = 0; i <= amount; i++) {
|
||||
dataSources.push({
|
||||
access: '',
|
||||
basicAuth: false,
|
||||
database: `database-${i}`,
|
||||
id: i,
|
||||
isDefault: false,
|
||||
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
|
||||
name: `dataSource-${i}`,
|
||||
orgId: 1,
|
||||
password: '',
|
||||
readOnly: false,
|
||||
type: 'cloudwatch',
|
||||
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
|
||||
url: '',
|
||||
user: '',
|
||||
});
|
||||
}
|
||||
|
||||
return dataSources;
|
||||
};
|
||||
|
||||
export const getMockDataSource = (): DataSource => {
|
||||
return {
|
||||
access: '',
|
||||
basicAuth: false,
|
||||
database: '',
|
||||
id: 13,
|
||||
isDefault: false,
|
||||
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
|
||||
name: 'gdev-cloudwatch',
|
||||
orgId: 1,
|
||||
password: '',
|
||||
readOnly: false,
|
||||
type: 'cloudwatch',
|
||||
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
|
||||
url: '',
|
||||
user: '',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<section
|
||||
className="card-section card-list-layout-grid"
|
||||
>
|
||||
<ol
|
||||
className="card-list"
|
||||
>
|
||||
<DataSourcesListItem
|
||||
dataSource={
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-0",
|
||||
"id": 0,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-0",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
}
|
||||
}
|
||||
key="0-0"
|
||||
/>
|
||||
<DataSourcesListItem
|
||||
dataSource={
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-1",
|
||||
"id": 1,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-1",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
}
|
||||
}
|
||||
key="1-1"
|
||||
/>
|
||||
<DataSourcesListItem
|
||||
dataSource={
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-2",
|
||||
"id": 2,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-2",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
}
|
||||
}
|
||||
key="2-2"
|
||||
/>
|
||||
<DataSourcesListItem
|
||||
dataSource={
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-3",
|
||||
"id": 3,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-3",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
}
|
||||
}
|
||||
key="3-3"
|
||||
/>
|
||||
</ol>
|
||||
</section>
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<li
|
||||
className="card-item-wrapper"
|
||||
>
|
||||
<a
|
||||
className="card-item"
|
||||
href="datasources/edit/13"
|
||||
>
|
||||
<div
|
||||
className="card-item-header"
|
||||
>
|
||||
<div
|
||||
className="card-item-type"
|
||||
>
|
||||
cloudwatch
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card-item-body"
|
||||
>
|
||||
<figure
|
||||
className="card-item-figure"
|
||||
>
|
||||
<img
|
||||
src="public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png"
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="card-item-details"
|
||||
>
|
||||
<div
|
||||
className="card-item-name"
|
||||
>
|
||||
gdev-cloudwatch
|
||||
</div>
|
||||
<div
|
||||
className="card-item-sub-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
@@ -0,0 +1,164 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render action bar and datasources 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(DataSourcesActionBar)
|
||||
key="action-bar"
|
||||
/>
|
||||
<DataSourcesList
|
||||
dataSources={
|
||||
Array [
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-0",
|
||||
"id": 0,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-0",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-1",
|
||||
"id": 1,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-1",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-2",
|
||||
"id": 2,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-2",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-3",
|
||||
"id": 3,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-3",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-4",
|
||||
"id": 4,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-4",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
Object {
|
||||
"access": "",
|
||||
"basicAuth": false,
|
||||
"database": "database-5",
|
||||
"id": 5,
|
||||
"isDefault": false,
|
||||
"jsonData": Object {
|
||||
"authType": "credentials",
|
||||
"defaultRegion": "eu-west-2",
|
||||
},
|
||||
"name": "dataSource-5",
|
||||
"orgId": 1,
|
||||
"password": "",
|
||||
"readOnly": false,
|
||||
"type": "cloudwatch",
|
||||
"typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
|
||||
"url": "",
|
||||
"user": "",
|
||||
},
|
||||
]
|
||||
}
|
||||
key="list"
|
||||
layoutMode="grid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "gicon gicon-add-datasources",
|
||||
"buttonLink": "datasources/new",
|
||||
"buttonTitle": "Add data source",
|
||||
"proTip": "You can also define data sources through configuration files.",
|
||||
"proTipLink": "http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list",
|
||||
"proTipLinkTitle": "Learn more",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "There are no data sources defined yet",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
import { DataSource, StoreState } from 'app/types';
|
||||
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||
import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
export enum ActionTypes {
|
||||
LoadDataSources = 'LOAD_DATA_SOURCES',
|
||||
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
|
||||
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
|
||||
}
|
||||
|
||||
export interface LoadDataSourcesAction {
|
||||
type: ActionTypes.LoadDataSources;
|
||||
payload: DataSource[];
|
||||
}
|
||||
|
||||
export interface SetDataSourcesSearchQueryAction {
|
||||
type: ActionTypes.SetDataSourcesSearchQuery;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface SetDataSourcesLayoutModeAction {
|
||||
type: ActionTypes.SetDataSourcesLayoutMode;
|
||||
payload: LayoutMode;
|
||||
}
|
||||
|
||||
const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
|
||||
type: ActionTypes.LoadDataSources,
|
||||
payload: dataSources,
|
||||
});
|
||||
|
||||
export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
|
||||
type: ActionTypes.SetDataSourcesSearchQuery,
|
||||
payload: searchQuery,
|
||||
});
|
||||
|
||||
export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({
|
||||
type: ActionTypes.SetDataSourcesLayoutMode,
|
||||
payload: layoutMode,
|
||||
});
|
||||
|
||||
export type Action = LoadDataSourcesAction | SetDataSourcesSearchQueryAction | SetDataSourcesLayoutModeAction;
|
||||
|
||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||
|
||||
export function loadDataSources(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const response = await getBackendSrv().get('/api/datasources');
|
||||
dispatch(dataSourcesLoaded(response));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DataSource, DataSourcesState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
const initialState: DataSourcesState = {
|
||||
dataSources: [] as DataSource[],
|
||||
layoutMode: LayoutModes.Grid,
|
||||
searchQuery: '',
|
||||
dataSourcesCount: 0,
|
||||
};
|
||||
|
||||
export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadDataSources:
|
||||
return { ...state, dataSources: action.payload, dataSourcesCount: action.payload.length };
|
||||
|
||||
case ActionTypes.SetDataSourcesSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
case ActionTypes.SetDataSourcesLayoutMode:
|
||||
return { ...state, layoutMode: action.payload };
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default {
|
||||
dataSources: dataSourcesReducer,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const getDataSources = state => {
|
||||
const regex = new RegExp(state.searchQuery, 'i');
|
||||
|
||||
return state.dataSources.filter(dataSource => {
|
||||
return regex.test(dataSource.name) || regex.test(dataSource.database);
|
||||
});
|
||||
};
|
||||
|
||||
export const getDataSourcesSearchQuery = state => state.searchQuery;
|
||||
export const getDataSourcesLayoutMode = state => state.layoutMode;
|
||||
export const getDataSourcesCount = state => state.dataSourcesCount;
|
||||
@@ -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 } 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,31 +59,6 @@ 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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,5 @@ import './plugin_page_ctrl';
|
||||
import './import_list/import_list';
|
||||
import './ds_edit_ctrl';
|
||||
import './ds_dashboards_ctrl';
|
||||
import './ds_list_ctrl';
|
||||
import './datasource_srv';
|
||||
import './plugin_component';
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import coreModule from '../../core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class DataSourcesCtrl {
|
||||
datasources: any;
|
||||
unfiltered: any;
|
||||
navModel: any;
|
||||
searchQuery: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private backendSrv, private datasourceSrv, private navModelSrv) {
|
||||
this.navModel = this.navModelSrv.getNav('cfg', 'datasources', 0);
|
||||
backendSrv.get('/api/datasources').then(result => {
|
||||
this.datasources = result;
|
||||
this.unfiltered = result;
|
||||
});
|
||||
}
|
||||
|
||||
onQueryUpdated() {
|
||||
const regex = new RegExp(this.searchQuery, 'ig');
|
||||
this.datasources = _.filter(this.unfiltered, item => {
|
||||
regex.lastIndex = 0;
|
||||
return regex.test(item.name) || regex.test(item.type);
|
||||
});
|
||||
}
|
||||
|
||||
removeDataSourceConfirmed(ds) {
|
||||
this.backendSrv
|
||||
.delete('/api/datasources/' + ds.id)
|
||||
.then(
|
||||
() => {
|
||||
this.$scope.appEvent('alert-success', ['Datasource deleted', '']);
|
||||
},
|
||||
() => {
|
||||
this.$scope.appEvent('alert-error', ['Unable to delete datasource', '']);
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.backendSrv.get('/api/datasources').then(result => {
|
||||
this.datasources = result;
|
||||
});
|
||||
this.backendSrv.get('/api/frontend/settings').then(settings => {
|
||||
this.datasourceSrv.init(settings.datasources);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeDataSource(ds) {
|
||||
this.$scope.appEvent('confirm-modal', {
|
||||
title: 'Delete',
|
||||
text: 'Are you sure you want to delete datasource ' + ds.name + '?',
|
||||
yesText: 'Delete',
|
||||
icon: 'fa-trash',
|
||||
onConfirm: () => {
|
||||
this.removeDataSourceConfirmed(ds);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('DataSourcesCtrl', DataSourcesCtrl);
|
||||
@@ -1,63 +0,0 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<div ng-if="ctrl.unfiltered.length">
|
||||
<div class="page-action-bar">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<label class="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 name or type" />
|
||||
<i class="gf-form-input-icon fa fa-search"></i>
|
||||
</label>
|
||||
<layout-selector />
|
||||
</div>
|
||||
<div class="page-action-bar__spacer"></div>
|
||||
<a class="page-header__cta btn btn-success" href="datasources/new">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add data source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="card-section" layout-mode>
|
||||
<ol class="card-list">
|
||||
<li class="card-item-wrapper" ng-repeat="ds in ctrl.datasources">
|
||||
<a class="card-item" href="datasources/edit/{{ds.id}}/">
|
||||
<div class="card-item-header">
|
||||
<div class="card-item-type">
|
||||
{{ds.type}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item-body">
|
||||
<figure class="card-item-figure">
|
||||
<img ng-src="{{ds.typeLogoUrl}}">
|
||||
</figure>
|
||||
<div class="card-item-details">
|
||||
<div class="card-item-name">
|
||||
{{ds.name}}
|
||||
<span ng-if="ds.isDefault">
|
||||
<span class="btn btn-secondary btn-mini">default</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-item-sub-name">
|
||||
{{ds.url}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.unfiltered.length === 0">
|
||||
<empty-list-cta model="{
|
||||
title: 'There are no data sources defined yet',
|
||||
buttonIcon: 'gicon gicon-add-datasources',
|
||||
buttonLink: 'datasources/new',
|
||||
buttonTitle: 'Add data source',
|
||||
proTip: 'You can also define data sources through configuration files.',
|
||||
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
|
||||
proTipLinkTitle: 'Learn more',
|
||||
proTipTarget: '_blank'
|
||||
}" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -16,8 +16,6 @@ export default class InfluxDatasource {
|
||||
basicAuth: any;
|
||||
withCredentials: any;
|
||||
interval: any;
|
||||
supportAnnotations: boolean;
|
||||
supportMetrics: boolean;
|
||||
responseParser: any;
|
||||
|
||||
/** @ngInject */
|
||||
@@ -34,8 +32,6 @@ export default class InfluxDatasource {
|
||||
this.basicAuth = instanceSettings.basicAuth;
|
||||
this.withCredentials = instanceSettings.withCredentials;
|
||||
this.interval = (instanceSettings.jsonData || {}).timeInterval;
|
||||
this.supportAnnotations = true;
|
||||
this.supportMetrics = true;
|
||||
this.responseParser = new ResponseParser();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export default class OpenTsDatasource {
|
||||
basicAuth: any;
|
||||
tsdbVersion: any;
|
||||
tsdbResolution: any;
|
||||
supportMetrics: any;
|
||||
tagKeys: any;
|
||||
|
||||
aggregatorsPromise: any;
|
||||
@@ -26,7 +25,6 @@ export default class OpenTsDatasource {
|
||||
instanceSettings.jsonData = instanceSettings.jsonData || {};
|
||||
this.tsdbVersion = instanceSettings.jsonData.tsdbVersion || 1;
|
||||
this.tsdbResolution = instanceSettings.jsonData.tsdbResolution || 1;
|
||||
this.supportMetrics = true;
|
||||
this.tagKeys = {};
|
||||
|
||||
this.aggregatorsPromise = null;
|
||||
|
||||
@@ -149,8 +149,6 @@ export class PrometheusDatasource {
|
||||
editorSrc: string;
|
||||
name: string;
|
||||
ruleMappings: { [index: string]: string };
|
||||
supportsExplore: boolean;
|
||||
supportMetrics: boolean;
|
||||
url: string;
|
||||
directUrl: string;
|
||||
basicAuth: any;
|
||||
@@ -166,8 +164,6 @@ export class PrometheusDatasource {
|
||||
this.type = 'prometheus';
|
||||
this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
|
||||
this.name = instanceSettings.name;
|
||||
this.supportsExplore = true;
|
||||
this.supportMetrics = true;
|
||||
this.url = instanceSettings.url;
|
||||
this.directUrl = instanceSettings.directUrl;
|
||||
this.basicAuth = instanceSettings.basicAuth;
|
||||
@@ -522,10 +518,10 @@ export class PrometheusDatasource {
|
||||
});
|
||||
}
|
||||
|
||||
getExploreState(panel) {
|
||||
getExploreState(targets: any[]) {
|
||||
let state = {};
|
||||
if (panel.targets) {
|
||||
const queries = panel.targets.map(t => ({
|
||||
if (targets && targets.length > 0) {
|
||||
const queries = targets.map(t => ({
|
||||
query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr),
|
||||
format: t.format,
|
||||
}));
|
||||
|
||||
@@ -24,6 +24,7 @@ export class StackdriverAggregationCtrl {
|
||||
alignOptions: any[];
|
||||
target: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope) {
|
||||
this.$scope.ctrl = this;
|
||||
this.target = $scope.target;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FilterSegments, DefaultRemoveFilterValue } from './filter_segments';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export class StackdriverFilter {
|
||||
/** @ngInject */
|
||||
constructor() {
|
||||
return {
|
||||
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
|
||||
|
||||
@@ -9,6 +9,7 @@ import ApiKeys from 'app/features/api-keys/ApiKeysPage';
|
||||
import PluginListPage from 'app/features/plugins/PluginListPage';
|
||||
import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
|
||||
import FolderPermissions from 'app/features/folders/FolderPermissions';
|
||||
import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
|
||||
|
||||
/** @ngInject */
|
||||
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
@@ -63,9 +64,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/datasources', {
|
||||
templateUrl: 'public/app/features/plugins/partials/ds_list.html',
|
||||
controller: 'DataSourcesCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => DataSourcesListPage,
|
||||
},
|
||||
})
|
||||
.when('/datasources/edit/:id', {
|
||||
templateUrl: 'public/app/features/plugins/partials/ds_edit.html',
|
||||
|
||||
@@ -8,6 +8,7 @@ import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||
import foldersReducers from 'app/features/folders/state/reducers';
|
||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||
import pluginReducers from 'app/features/plugins/state/reducers';
|
||||
import dataSourcesReducers from 'app/features/datasources/state/reducers';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
...sharedReducers,
|
||||
@@ -17,6 +18,7 @@ const rootReducer = combineReducers({
|
||||
...foldersReducers,
|
||||
...dashboardReducers,
|
||||
...pluginReducers,
|
||||
...dataSourcesReducers,
|
||||
});
|
||||
|
||||
export let store;
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
export interface DataSource {
|
||||
id: number;
|
||||
orgId: number;
|
||||
name: string;
|
||||
typeLogoUrl: string;
|
||||
type: string;
|
||||
access: string;
|
||||
url: string;
|
||||
password: string;
|
||||
user: string;
|
||||
database: string;
|
||||
basicAuth: false;
|
||||
isDefault: false;
|
||||
jsonData: { authType: string; defaultRegion: string };
|
||||
readOnly: false;
|
||||
}
|
||||
|
||||
export interface DataSourcesState {
|
||||
dataSources: DataSource[];
|
||||
searchQuery: string;
|
||||
layoutMode: LayoutMode;
|
||||
dataSourcesCount: number;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@ export interface Query {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
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 interface ExploreUrlState {
|
||||
datasource: string;
|
||||
queries: Query[];
|
||||
|
||||
@@ -5,9 +5,9 @@ import { NavModel, NavModelItem, NavIndex } from './navModel';
|
||||
import { FolderDTO, FolderState, FolderInfo } from './folders';
|
||||
import { DashboardState } from './dashboard';
|
||||
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
|
||||
import { DataSource } from './datasources';
|
||||
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
|
||||
import { User } from './user';
|
||||
import { DataSource, DataSourcesState } from './datasources';
|
||||
import { PluginMeta, Plugin, PluginsState } from './plugins';
|
||||
|
||||
export {
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
User,
|
||||
Plugin,
|
||||
PluginsState,
|
||||
DataSourcesState,
|
||||
};
|
||||
|
||||
export interface StoreState {
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#/bin/bash
|
||||
|
||||
# no relation to publish.go
|
||||
|
||||
# Right now we hack this in into the publish script.
|
||||
# Eventually we might want to keep a list of all previous releases somewhere.
|
||||
_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-3-x/10244"
|
||||
_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-3/"
|
||||
|
||||
./release_publisher/release_publisher \
|
||||
--wn ${_whatsNewUrl} \
|
||||
--rn ${_releaseNoteUrl} \
|
||||
--version ${CIRCLE_TAG} \
|
||||
--apikey ${GRAFANA_COM_API_KEY}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var baseUri string = "https://grafana.com/api"
|
||||
|
||||
func main() {
|
||||
var version string
|
||||
var whatsNewUrl string
|
||||
var releaseNotesUrl string
|
||||
var dryRun bool
|
||||
var apiKey string
|
||||
|
||||
flag.StringVar(&version, "version", "", "Grafana version (ex: --version v5.2.0-beta1)")
|
||||
flag.StringVar(&whatsNewUrl, "wn", "", "What's new url (ex: --wn http://docs.grafana.org/guides/whats-new-in-v5-2/)")
|
||||
flag.StringVar(&releaseNotesUrl, "rn", "", "Grafana version (ex: --rn https://community.grafana.com/t/release-notes-v5-2-x/7894)")
|
||||
flag.StringVar(&apiKey, "apikey", "", "Grafana.com API key (ex: --apikey ABCDEF)")
|
||||
flag.BoolVar(&dryRun, "dry-run", false, "--dry-run")
|
||||
flag.Parse()
|
||||
|
||||
if len(os.Args) == 1 {
|
||||
fmt.Println("Usage: go run publisher.go main.go --version <v> --wn <what's new url> --rn <release notes url> --apikey <api key> --dry-run false")
|
||||
fmt.Println("example: go run publisher.go main.go --version v5.2.0-beta2 --wn http://docs.grafana.org/guides/whats-new-in-v5-2/ --rn https://community.grafana.com/t/release-notes-v5-2-x/7894 --apikey ASDF123 --dry-run true")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
log.Println("Dry-run has been enabled.")
|
||||
}
|
||||
|
||||
p := publisher{apiKey: apiKey}
|
||||
if err := p.doRelease(version, whatsNewUrl, releaseNotesUrl, dryRun); err != nil {
|
||||
log.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type publisher struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func (p *publisher) doRelease(version string, whatsNewUrl string, releaseNotesUrl string, dryRun bool) error {
|
||||
currentRelease, err := newRelease(version, whatsNewUrl, releaseNotesUrl, buildArtifactConfigurations, getHttpContents{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
relJson, err := json.Marshal(currentRelease)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println(string(relJson))
|
||||
|
||||
for _, b := range currentRelease.Builds {
|
||||
artifactJson, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println(string(artifactJson))
|
||||
}
|
||||
} else {
|
||||
if err := p.postRelease(currentRelease); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *publisher) postRelease(r *release) error {
|
||||
err := p.postRequest("/grafana/versions", r, fmt.Sprintf("Create Release %s", r.Version))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.postRequest("/grafana/versions/"+r.Version, r, fmt.Sprintf("Update Release %s", r.Version))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range r.Builds {
|
||||
err = p.postRequest(fmt.Sprintf("/grafana/versions/%s/packages", r.Version), b, fmt.Sprintf("Create Build %s %s", b.Os, b.Arch))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.postRequest(fmt.Sprintf("/grafana/versions/%s/packages/%s/%s", r.Version, b.Arch, b.Os), b, fmt.Sprintf("Update Build %s %s", b.Os, b.Arch))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const baseArhiveUrl = "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana"
|
||||
|
||||
type buildArtifact struct {
|
||||
os string
|
||||
arch string
|
||||
urlPostfix string
|
||||
}
|
||||
|
||||
func (t buildArtifact) getUrl(version string, isBeta bool) string {
|
||||
prefix := "-"
|
||||
rhelReleaseExtra := ""
|
||||
|
||||
if t.os == "deb" {
|
||||
prefix = "_"
|
||||
}
|
||||
|
||||
if !isBeta && t.os == "rhel" {
|
||||
rhelReleaseExtra = "-1"
|
||||
}
|
||||
|
||||
url := strings.Join([]string{baseArhiveUrl, prefix, version, rhelReleaseExtra, t.urlPostfix}, "")
|
||||
return url
|
||||
}
|
||||
|
||||
var buildArtifactConfigurations = []buildArtifact{
|
||||
{
|
||||
os: "deb",
|
||||
arch: "arm64",
|
||||
urlPostfix: "_arm64.deb",
|
||||
},
|
||||
{
|
||||
os: "rhel",
|
||||
arch: "arm64",
|
||||
urlPostfix: ".aarch64.rpm",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "arm64",
|
||||
urlPostfix: ".linux-arm64.tar.gz",
|
||||
},
|
||||
{
|
||||
os: "deb",
|
||||
arch: "armv7",
|
||||
urlPostfix: "_armhf.deb",
|
||||
},
|
||||
{
|
||||
os: "rhel",
|
||||
arch: "armv7",
|
||||
urlPostfix: ".armhfp.rpm",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "armv7",
|
||||
urlPostfix: ".linux-armv7.tar.gz",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "amd64",
|
||||
urlPostfix: ".darwin-amd64.tar.gz",
|
||||
},
|
||||
{
|
||||
os: "deb",
|
||||
arch: "amd64",
|
||||
urlPostfix: "_amd64.deb",
|
||||
},
|
||||
{
|
||||
os: "rhel",
|
||||
arch: "amd64",
|
||||
urlPostfix: ".x86_64.rpm",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "amd64",
|
||||
urlPostfix: ".linux-amd64.tar.gz",
|
||||
},
|
||||
{
|
||||
os: "win",
|
||||
arch: "amd64",
|
||||
urlPostfix: ".windows-amd64.zip",
|
||||
},
|
||||
}
|
||||
|
||||
func newRelease(rawVersion string, whatsNewUrl string, releaseNotesUrl string, artifactConfigurations []buildArtifact, getter urlGetter) (*release, error) {
|
||||
version := rawVersion[1:]
|
||||
now := time.Now()
|
||||
isBeta := strings.Contains(version, "beta")
|
||||
|
||||
builds := []build{}
|
||||
for _, ba := range artifactConfigurations {
|
||||
sha256, err := getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(version, isBeta)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, newBuild(ba, version, isBeta, sha256))
|
||||
}
|
||||
|
||||
r := release{
|
||||
Version: version,
|
||||
ReleaseDate: time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local),
|
||||
Stable: !isBeta,
|
||||
Beta: isBeta,
|
||||
Nightly: false,
|
||||
WhatsNewUrl: whatsNewUrl,
|
||||
ReleaseNotesUrl: releaseNotesUrl,
|
||||
Builds: builds,
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func newBuild(ba buildArtifact, version string, isBeta bool, sha256 string) build {
|
||||
return build{
|
||||
Os: ba.os,
|
||||
Url: ba.getUrl(version, isBeta),
|
||||
Sha256: sha256,
|
||||
Arch: ba.arch,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *publisher) postRequest(url string, obj interface{}, desc string) error {
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, baseUri+url, bytes.NewReader(jsonBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+p.apiKey)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode == http.StatusOK {
|
||||
log.Printf("Action: %s \t OK", desc)
|
||||
return nil
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer res.Body.Close()
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.Contains(string(body), "already exists") || strings.Contains(string(body), "Nothing to update") {
|
||||
log.Printf("Action: %s \t Already exists", desc)
|
||||
} else {
|
||||
log.Printf("Action: %s \t Failed - Status: %v", desc, res.Status)
|
||||
log.Printf("Resp: %s", body)
|
||||
log.Fatalf("Quiting")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type release struct {
|
||||
Version string `json:"version"`
|
||||
ReleaseDate time.Time `json:"releaseDate"`
|
||||
Stable bool `json:"stable"`
|
||||
Beta bool `json:"beta"`
|
||||
Nightly bool `json:"nightly"`
|
||||
WhatsNewUrl string `json:"whatsNewUrl"`
|
||||
ReleaseNotesUrl string `json:"releaseNotesUrl"`
|
||||
Builds []build `json:"-"`
|
||||
}
|
||||
|
||||
type build struct {
|
||||
Os string `json:"os"`
|
||||
Url string `json:"url"`
|
||||
Sha256 string `json:"sha256"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
|
||||
type urlGetter interface {
|
||||
getContents(url string) (string, error)
|
||||
}
|
||||
|
||||
type getHttpContents struct{}
|
||||
|
||||
func (getHttpContents) getContents(url string) (string, error) {
|
||||
response, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
all, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(all), nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewRelease(t *testing.T) {
|
||||
versionIn := "v5.2.0-beta1"
|
||||
expectedVersion := "5.2.0-beta1"
|
||||
whatsNewUrl := "https://whatsnews.foo/"
|
||||
relNotesUrl := "https://relnotes.foo/"
|
||||
expectedArch := "amd64"
|
||||
expectedOs := "linux"
|
||||
buildArtifacts := []buildArtifact{{expectedOs, expectedArch, ".linux-amd64.tar.gz"}}
|
||||
|
||||
rel, _ := newRelease(versionIn, whatsNewUrl, relNotesUrl, buildArtifacts, mockHttpGetter{})
|
||||
|
||||
if !rel.Beta || rel.Stable {
|
||||
t.Errorf("%s should have been tagged as beta (not stable), but wasn't .", versionIn)
|
||||
}
|
||||
|
||||
if rel.Version != expectedVersion {
|
||||
t.Errorf("Expected version to be %s, but it was %s.", expectedVersion, rel.Version)
|
||||
}
|
||||
|
||||
expectedBuilds := len(buildArtifacts)
|
||||
if len(rel.Builds) != expectedBuilds {
|
||||
t.Errorf("Expected %v builds, but got %v.", expectedBuilds, len(rel.Builds))
|
||||
}
|
||||
|
||||
build := rel.Builds[0]
|
||||
if build.Arch != expectedArch {
|
||||
t.Errorf("Expected arch to be %v, but it was %v", expectedArch, build.Arch)
|
||||
}
|
||||
|
||||
if build.Os != expectedOs {
|
||||
t.Errorf("Expected arch to be %v, but it was %v", expectedOs, build.Os)
|
||||
}
|
||||
}
|
||||
|
||||
type mockHttpGetter struct{}
|
||||
|
||||
func (mockHttpGetter) getContents(url string) (string, error) {
|
||||
return url, nil
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 VividCortex
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+1080
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user