Merge remote-tracking branch 'grafana/master'

* grafana/master: (93 commits)
  updated publish script
  Update CHANGELOG.md
  fix time regions bugs
  fixed issue with colorpicker position above window, fixes #14412
  fixed issue with singlestat and repeated scopedVars, was only working for time series data sources, and only if there was any series, now scoped vars is always set, fixes #14367
  fix search tag issues, fixes #14391
  Clear query models when changing data source type, fixes #14394
  Use correct variable name in fail text
  Fix logs panel meta wrap
  Explore: dont pass all rows to all rows, fixes profiler
  Explore: Logging dedup tooltips
  Explore: Hide scanning again after result was found
  Explore: Fix timepicker inputs for absolute dates
  Switch to global match for full browser support of escaped custom vars
  Allow backslash escaping in custom variables
  Fixed issue with logs graph and stacking
  align yellow collor with graph in logs table
  Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate.  Pull request #14399
  allow sidemenu sections without children still have a hover menu/header
  changelog: adds note about closing #11221
  ...
This commit is contained in:
ryan
2018-12-10 16:52:19 -08:00
108 changed files with 3293 additions and 736 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": { "browsers": "last 3 versions" },
"useBuiltIns": "entry"
}
]
]
}
+22
View File
@@ -1,10 +1,32 @@
# 5.5.0 (unreleased)
### New Features
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
### Minor
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
# 5.4.1 (2018-12-10)
* **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262)
* **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999).
* **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319)
* **InfluxDB**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti)
* **Plugins**: Panel plugins should no receive the panel-initialized event again as usual.
* **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284)
* **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313](
https://github.com/grafana/grafana/pull/14313)
* **Quotas**: Fixed for updating org & user quotas. [#14347](https://github.com/grafana/grafana/pull/14347), thx [#moznion](https://github.com/moznion)
* **Cloudwatch**: Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate to auto complete list. [#14401](https://github.com/grafana/grafana/pull/14401), thx [@sglajchEG](https://github.com/sglajchEG)
* **Dashboard Search**: Fixed filtering by tag issues.
* **Graph**: Fixed time region issues, [#14425](https://github.com/grafana/grafana/issues/14425), [#14280](https://github.com/grafana/grafana/issues/14280)
* **Graph**: Fixed issue with series color picker popover being placed outside window.
# 5.4.0 (2018-12-03)
+20 -18
View File
@@ -157,27 +157,29 @@ There are a couple of configuration options which need to be set up in Grafana U
Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
### All supported notifiers
### Google Hangouts Chat
Name | Type |Support images | Support reminders
-----|------------ | ------ | ------ |
Slack | `slack` | yes | yes
Pagerduty | `pagerduty` | yes | yes
Email | `email` | yes | yes
Webhook | `webhook` | link | yes
Kafka | `kafka` | no | yes
Hipchat | `hipchat` | yes | yes
VictorOps | `victorops` | yes | yes
Sensu | `sensu` | yes | yes
OpsGenie | `opsgenie` | yes | yes
Threema | `threema` | yes | yes
Pushover | `pushover` | no | yes
Telegram | `telegram` | no | yes
Line | `line` | no | yes
Microsoft Teams | `teams` | yes | yes
Prometheus Alertmanager | `prometheus-alertmanager` | no | no
Notifications can be sent by setting up an incoming webhook in Google Hangouts chat. Configuring such a webhook is described [here](https://developers.google.com/hangouts/chat/how-tos/webhooks).
### All supported notifier
Name | Type |Support images
-----|------------ | ------
Slack | `slack` | yes
Pagerduty | `pagerduty` | yes
Email | `email` | yes
Webhook | `webhook` | link
Kafka | `kafka` | no
Google Hangouts Chat | `googlechat` | yes
Hipchat | `hipchat` | yes
VictorOps | `victorops` | yes
Sensu | `sensu` | yes
OpsGenie | `opsgenie` | yes
Threema | `threema` | yes
Pushover | `pushover` | no
Telegram | `telegram` | no
Line | `line` | no
Prometheus Alertmanager | `prometheus-alertmanager` | no
# Enable images in notifications {#external-image-store}
+15 -19
View File
@@ -10,6 +10,12 @@
"url": "http://github.com/grafana/grafana.git"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.1.0",
"@types/d3": "^4.10.1",
"@types/enzyme": "^3.1.13",
"@types/jest": "^23.3.2",
@@ -21,10 +27,10 @@
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
"axios": "^0.17.1",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.4",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-preset-es2015": "^6.24.1",
"babel-core": "^7.0.0-bridge",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"babel-plugin-angularjs-annotate": "^0.9.0",
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^0.28.7",
"enzyme": "^3.6.0",
@@ -108,18 +114,9 @@
"precommit": "lint-staged && grunt precommit"
},
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write",
"git add"
],
"*.scss": [
"prettier --write",
"git add"
],
"*pkg/**/*.go": [
"gofmt -w -s",
"git add"
]
"*.{ts,tsx}": ["prettier --write", "git add"],
"*.scss": ["prettier --write", "git add"],
"*pkg/**/*.go": ["gofmt -w -s", "git add"]
},
"prettier": {
"trailingComma": "es5",
@@ -128,13 +125,12 @@
},
"license": "Apache-2.0",
"dependencies": {
"@babel/polyfill": "^7.0.0",
"angular": "1.6.6",
"angular-bindonce": "0.3.1",
"angular-native-dragdrop": "1.2.2",
"angular-route": "1.6.6",
"angular-sanitize": "1.6.6",
"babel-jest": "^23.6.0",
"babel-polyfill": "^6.26.0",
"baron": "^3.0.3",
"brace": "^0.10.0",
"classnames": "^2.2.5",
@@ -156,7 +152,7 @@
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.5.0",
"react-grid-layout": "0.16.6",
"react-highlight-words": "^0.10.0",
"react-highlight-words": "0.11.0",
"react-popper": "^0.7.5",
"react-redux": "^5.0.7",
"react-select": "2.1.0",
+5
View File
@@ -18,3 +18,8 @@ docker build \
.
docker push "${_docker_repo}:${_grafana_tag}"
if echo "$_raw_grafana_tag" | grep -q "^v" && echo "$_raw_grafana_tag" | grep -qv "beta"; then
docker tag "${_docker_repo}:${_grafana_tag}" "${_docker_repo}:latest"
docker push "${_docker_repo}:latest"
fi
+3 -3
View File
@@ -1,7 +1,7 @@
#! /usr/bin/env bash
version=5.0.2
version=5.4.1
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb
wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
@@ -11,7 +11,7 @@ package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${version}-1.x86_64.rpm
wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm
package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose
+6
View File
@@ -76,6 +76,7 @@ func AdminUpdateUserPassword(c *m.ReqContext, form dtos.AdminUpdateUserPasswordF
c.JsonOK("User password updated")
}
// PUT /api/admin/users/:id/permissions
func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermissionsForm) {
userID := c.ParamsInt64(":id")
@@ -85,6 +86,11 @@ func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermis
}
if err := bus.Dispatch(&cmd); err != nil {
if err == m.ErrLastGrafanaAdmin {
c.JsonApiErr(400, m.ErrLastGrafanaAdmin.Error(), nil)
return
}
c.JsonApiErr(500, "Failed to update user permissions", err)
return
}
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"testing"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestAdminApiEndpoint(t *testing.T) {
role := m.ROLE_ADMIN
Convey("Given a server admin attempts to remove themself as an admin", t, func() {
updateCmd := dtos.AdminUpdateUserPermissionsForm{
IsGrafanaAdmin: false,
}
bus.AddHandler("test", func(cmd *m.UpdateUserPermissionsCommand) error {
return m.ErrLastGrafanaAdmin
})
putAdminScenario("When calling PUT on", "/api/admin/users/1/permissions", "/api/admin/users/:id/permissions", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 400)
})
})
}
func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
sc := setupScenarioContext(url)
sc.defaultHandler = Wrap(func(c *m.ReqContext) {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
AdminUpdateUserPermissions(c, cmd)
})
sc.m.Put(routePattern, sc.defaultHandler)
fn(sc)
})
}
-3
View File
@@ -147,9 +147,6 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
SubTitle: "Explore your data",
Icon: "fa fa-rocket",
Url: setting.AppSubUrl + "/explore",
Children: []*dtos.NavLink{
{Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore"},
},
})
}
+1 -1
View File
@@ -87,7 +87,7 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
}
for key, value := range headers {
log.Trace("setting key %v value %v", key, value[0])
log.Trace("setting key %v value <redacted>", key)
req.Header.Set(key, value[0])
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
// uses code from https://github.com/antonholmquist/jason/blob/master/jason.go
// MIT Licence
// MIT License
package dynmap
+1 -1
View File
@@ -1,5 +1,5 @@
// uses code from https://github.com/antonholmquist/jason/blob/master/jason.go
// MIT Licence
// MIT License
package dynmap
+2 -1
View File
@@ -7,7 +7,8 @@ import (
// Typed errors
var (
ErrUserNotFound = errors.New("User not found")
ErrUserNotFound = errors.New("User not found")
ErrLastGrafanaAdmin = errors.New("Cannot remove last grafana admin")
)
type Password string
@@ -0,0 +1,215 @@
package notifiers
import (
"encoding/json"
"fmt"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/setting"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "googlechat",
Name: "Google Hangouts Chat",
Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message " +
"format (https://developers.google.com/hangouts/chat/reference/message-formats/).",
Factory: NewGoogleChatNotifier,
OptionsTemplate: `
<h3 class="page-heading">Google Hangouts Chat settings</h3>
<div class="gf-form max-width-30">
<span class="gf-form-label width-6">Url</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Google Hangouts Chat incoming webhook url"></input>
</div>
`,
})
}
func NewGoogleChatNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
url := model.Settings.Get("url").MustString()
if url == "" {
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
}
return &GoogleChatNotifier{
NotifierBase: NewNotifierBase(model),
Url: url,
log: log.New("alerting.notifier.googlechat"),
}, nil
}
type GoogleChatNotifier struct {
NotifierBase
Url string
log log.Logger
}
/**
Structs used to build a custom Google Hangouts Chat message card.
See: https://developers.google.com/hangouts/chat/reference/message-formats/cards
*/
type outerStruct struct {
Cards []card `json:"cards"`
}
type card struct {
Header header `json:"header"`
Sections []section `json:"sections"`
}
type header struct {
Title string `json:"title"`
}
type section struct {
Widgets []widget `json:"widgets"`
}
// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget)
type widget interface {
}
type buttonWidget struct {
Buttons []button `json:"buttons"`
}
type textParagraphWidget struct {
Text text `json:"textParagraph"`
}
type text struct {
Text string `json:"text"`
}
type imageWidget struct {
Image image `json:"image"`
}
type image struct {
ImageUrl string `json:"imageUrl"`
}
type button struct {
TextButton textButton `json:"textButton"`
}
type textButton struct {
Text string `json:"text"`
OnClick onClick `json:"onClick"`
}
type onClick struct {
OpenLink openLink `json:"openLink"`
}
type openLink struct {
Url string `json:"url"`
}
func (this *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Info("Executing Google Chat notification")
headers := map[string]string{
"Content-Type": "application/json; charset=UTF-8",
}
ruleUrl, err := evalContext.GetRuleUrl()
if err != nil {
this.log.Error("evalContext returned an invalid rule URL")
}
// add a text paragraph widget for the message
widgets := []widget{
textParagraphWidget{
Text: text{
Text: evalContext.Rule.Message,
},
},
}
// add a text paragraph widget for the fields
var fields []textParagraphWidget
fieldLimitCount := 4
for index, evt := range evalContext.EvalMatches {
fields = append(fields,
textParagraphWidget{
Text: text{
Text: "<i>" + evt.Metric + ": " + fmt.Sprint(evt.Value) + "</i>",
},
},
)
if index > fieldLimitCount {
break
}
}
widgets = append(widgets, fields)
// if an image exists, add it as an image widget
if evalContext.ImagePublicUrl != "" {
widgets = append(widgets, imageWidget{
Image: image{
ImageUrl: evalContext.ImagePublicUrl,
},
})
} else {
this.log.Info("Could not retrieve a public image URL.")
}
// add a button widget (link to Grafana)
widgets = append(widgets, buttonWidget{
Buttons: []button{
{
TextButton: textButton{
Text: "OPEN IN GRAFANA",
OnClick: onClick{
OpenLink: openLink{
Url: ruleUrl,
},
},
},
},
},
})
// add text paragraph widget for the build version and timestamp
widgets = append(widgets, textParagraphWidget{
Text: text{
Text: "Grafana v" + setting.BuildVersion + " | " + (time.Now()).Format(time.RFC822),
},
})
// nest the required structs
res1D := &outerStruct{
Cards: []card{
{
Header: header{
Title: evalContext.GetNotificationTitle(),
},
Sections: []section{
{
Widgets: widgets,
},
},
},
},
}
body, _ := json.Marshal(res1D)
cmd := &m.SendWebhookSync{
Url: this.Url,
HttpMethod: "POST",
HttpHeader: headers,
Body: string(body),
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", this.Name)
return err
}
return nil
}
@@ -0,0 +1,53 @@
package notifiers
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestGoogleChatNotifier(t *testing.T) {
Convey("Google Hangouts Chat notifier tests", t, func() {
Convey("Parsing alert notification from settings", func() {
Convey("empty settings should return error", func() {
json := `{ }`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "ops",
Type: "googlechat",
Settings: settingsJSON,
}
_, err := NewGoogleChatNotifier(model)
So(err, ShouldNotBeNil)
})
Convey("from settings", func() {
json := `
{
"url": "http://google.com"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "ops",
Type: "googlechat",
Settings: settingsJSON,
}
not, err := NewGoogleChatNotifier(model)
webhookNotifier := not.(*GoogleChatNotifier)
So(err, ShouldBeNil)
So(webhookNotifier.Name, ShouldEqual, "ops")
So(webhookNotifier.Type, ShouldEqual, "googlechat")
So(webhookNotifier.Url, ShouldEqual, "http://google.com")
})
})
})
}
+1 -1
View File
@@ -187,7 +187,7 @@ func TestAccountDataAccess(t *testing.T) {
err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId})
So(err, ShouldBeNil)
// remove frome ac2 from ac1 org
// remove ac2 user from ac1 org
remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true}
err = RemoveOrgUser(&remCmd)
So(err, ShouldBeNil)
+6 -6
View File
@@ -99,14 +99,14 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error {
return inTransaction(func(sess *DBSession) error {
//Check if quota is already defined in the DB
quota := m.Quota{
Target: cmd.Target,
OrgId: cmd.OrgId,
Updated: time.Now(),
Target: cmd.Target,
OrgId: cmd.OrgId,
}
has, err := sess.Get(&quota)
if err != nil {
return err
}
quota.Updated = time.Now()
quota.Limit = cmd.Limit
if !has {
quota.Created = time.Now()
@@ -201,14 +201,14 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error {
return inTransaction(func(sess *DBSession) error {
//Check if quota is already defined in the DB
quota := m.Quota{
Target: cmd.Target,
UserId: cmd.UserId,
Updated: time.Now(),
Target: cmd.Target,
UserId: cmd.UserId,
}
has, err := sess.Get(&quota)
if err != nil {
return err
}
quota.Updated = time.Now()
quota.Limit = cmd.Limit
if !has {
quota.Created = time.Now()
+65
View File
@@ -2,6 +2,7 @@ package sqlstore
import (
"testing"
"time"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
@@ -168,5 +169,69 @@ func TestQuotaCommandsAndQueries(t *testing.T) {
So(query.Result.Limit, ShouldEqual, 5)
So(query.Result.Used, ShouldEqual, 1)
})
// related: https://github.com/grafana/grafana/issues/14342
Convey("Should org quota updating is successful even if it called multiple time", func() {
orgCmd := m.UpdateOrgQuotaCmd{
OrgId: orgId,
Target: "org_user",
Limit: 5,
}
err := UpdateOrgQuota(&orgCmd)
So(err, ShouldBeNil)
query := m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1}
err = GetOrgQuotaByTarget(&query)
So(err, ShouldBeNil)
So(query.Result.Limit, ShouldEqual, 5)
// XXX: resolution of `Updated` column is 1sec, so this makes delay
time.Sleep(1 * time.Second)
orgCmd = m.UpdateOrgQuotaCmd{
OrgId: orgId,
Target: "org_user",
Limit: 10,
}
err = UpdateOrgQuota(&orgCmd)
So(err, ShouldBeNil)
query = m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1}
err = GetOrgQuotaByTarget(&query)
So(err, ShouldBeNil)
So(query.Result.Limit, ShouldEqual, 10)
})
// related: https://github.com/grafana/grafana/issues/14342
Convey("Should user quota updating is successful even if it called multiple time", func() {
userQuotaCmd := m.UpdateUserQuotaCmd{
UserId: userId,
Target: "org_user",
Limit: 5,
}
err := UpdateUserQuota(&userQuotaCmd)
So(err, ShouldBeNil)
query := m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1}
err = GetUserQuotaByTarget(&query)
So(err, ShouldBeNil)
So(query.Result.Limit, ShouldEqual, 5)
// XXX: resolution of `Updated` column is 1sec, so this makes delay
time.Sleep(1 * time.Second)
userQuotaCmd = m.UpdateUserQuotaCmd{
UserId: userId,
Target: "org_user",
Limit: 10,
}
err = UpdateUserQuota(&userQuotaCmd)
So(err, ShouldBeNil)
query = m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1}
err = GetUserQuotaByTarget(&query)
So(err, ShouldBeNil)
So(query.Result.Limit, ShouldEqual, 10)
})
})
}
+25 -1
View File
@@ -504,8 +504,18 @@ func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error {
user.IsAdmin = cmd.IsGrafanaAdmin
sess.UseBool("is_admin")
_, err := sess.ID(user.Id).Update(&user)
return err
if err != nil {
return err
}
// validate that after update there is at least one server admin
if err := validateOneAdminLeft(sess); err != nil {
return err
}
return nil
})
}
@@ -522,3 +532,17 @@ func SetUserHelpFlag(cmd *m.SetUserHelpFlagCommand) error {
return err
})
}
func validateOneAdminLeft(sess *DBSession) error {
// validate that there is an admin user left
count, err := sess.Where("is_admin=?", true).Count(&m.User{})
if err != nil {
return err
}
if count == 0 {
return m.ErrLastGrafanaAdmin
}
return nil
}
+26
View File
@@ -155,6 +155,32 @@ func TestUserDataAccess(t *testing.T) {
})
})
})
Convey("Given one grafana admin user", func() {
var err error
createUserCmd := &m.CreateUserCommand{
Email: fmt.Sprint("admin", "@test.com"),
Name: fmt.Sprint("admin"),
Login: fmt.Sprint("admin"),
IsAdmin: true,
}
err = CreateUser(context.Background(), createUserCmd)
So(err, ShouldBeNil)
Convey("Cannot make themselves a non-admin", func() {
updateUserPermsCmd := m.UpdateUserPermissionsCommand{IsGrafanaAdmin: false, UserId: 1}
updatePermsError := UpdateUserPermissions(&updateUserPermsCmd)
So(updatePermsError, ShouldEqual, m.ErrLastGrafanaAdmin)
query := m.GetUserByIdQuery{Id: createUserCmd.Result.Id}
getUserError := GetUserById(&query)
So(getUserError, ShouldBeNil)
So(query.Result.IsAdmin, ShouldEqual, true)
})
})
})
}
+1 -1
View File
@@ -101,7 +101,7 @@ func init() {
"AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
"AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
"AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"},
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
"AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
"AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
"AWS/States": {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"},
+1 -1
View File
@@ -541,7 +541,7 @@ func getErrorFromElasticResponse(response *es.SearchResponse) *tsdb.QueryResult
} else if reason != "" {
result.ErrorString = reason
} else {
result.ErrorString = "Unkown elasticsearch error response"
result.ErrorString = "Unknown elasticsearch error response"
}
return result
+1
View File
@@ -32,6 +32,7 @@ func init() {
renders["median"] = QueryDefinition{Renderer: functionRenderer}
renders["sum"] = QueryDefinition{Renderer: functionRenderer}
renders["mode"] = QueryDefinition{Renderer: functionRenderer}
renders["cumulative_sum"] = QueryDefinition{Renderer: functionRenderer}
renders["holt_winters"] = QueryDefinition{
Renderer: functionRenderer,
+1
View File
@@ -23,6 +23,7 @@ func TestInfluxdbQueryPart(t *testing.T) {
{mode: "alias", params: []string{"test"}, input: "mean(value)", expected: `mean(value) AS "test"`},
{mode: "count", params: []string{}, input: "distinct(value)", expected: `count(distinct(value))`},
{mode: "mode", params: []string{}, input: "value", expected: `mode(value)`},
{mode: "cumulative_sum", params: []string{}, input: "mean(value)", expected: `cumulative_sum(mean(value))`},
}
queryContext := &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("5m", "now")}
+1 -1
View File
@@ -84,7 +84,7 @@ func (e *OpenTsdbExecutor) createRequest(dsInfo *models.DataSource, data OpenTsd
postData, err := json.Marshal(data)
if err != nil {
plog.Info("Failed marshalling data", "error", err)
plog.Info("Failed marshaling data", "error", err)
return nil, fmt.Errorf("Failed to create request. error: %v", err)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import 'babel-polyfill';
import '@babel/polyfill';
import 'file-saver';
import 'lodash';
import 'jquery';
+1 -1
View File
@@ -16,7 +16,7 @@ export function registerAngularDirectives() {
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['onSelect', { watchDepth: 'reference' }],
['onChange', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
}
@@ -84,7 +84,7 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
render() {
const { onCancel } = this.props;
const newItem = this.state;
const pickerClassName = 'width-20';
const pickerClassName = 'min-width-20';
const isValid = this.isValid();
return (
<div className="gf-form-inline cta-form">
@@ -40,7 +40,7 @@ export class UserPicker extends Component<Props, State> {
.then(result => {
return result.map(user => ({
id: user.userId,
label: `${user.login} - ${user.email}`,
label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
login: user.login,
}));
@@ -10,7 +10,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles';
export interface Props {
tags: string[];
tagOptions: () => any;
onSelect: (tag: string) => void;
onChange: (tags: string[]) => void;
}
export class TagFilter extends React.Component<Props, any> {
@@ -18,12 +18,9 @@ export class TagFilter extends React.Component<Props, any> {
constructor(props) {
super(props);
this.searchTags = this.searchTags.bind(this);
this.onChange = this.onChange.bind(this);
}
searchTags(query) {
onLoadOptions = query => {
return this.props.tagOptions().then(options => {
return options.map(option => ({
value: option.term,
@@ -31,18 +28,20 @@ export class TagFilter extends React.Component<Props, any> {
count: option.count,
}));
});
}
};
onChange(newTags) {
this.props.onSelect(newTags);
}
onChange = (newTags: any[]) => {
this.props.onChange(newTags.map(tag => tag.value));
};
render() {
const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 }));
const selectOptions = {
classNamePrefix: 'gf-form-select-box',
isMulti: true,
defaultOptions: true,
loadOptions: this.searchTags,
loadOptions: this.onLoadOptions,
onChange: this.onChange,
className: 'gf-form-input gf-form-input--form-dropdown',
placeholder: 'Tags',
@@ -50,7 +49,7 @@ export class TagFilter extends React.Component<Props, any> {
noOptionsMessage: () => 'No tags found',
getOptionValue: i => i.value,
getOptionLabel: i => i.label,
value: this.props.tags,
value: tags,
styles: ResetStyles,
components: {
Option: TagOption,
@@ -0,0 +1,76 @@
import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react';
interface ToggleButtonGroupProps {
onChange: (value) => void;
value?: any;
label?: string;
render: (props) => void;
}
export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
getValues() {
const { children } = this.props;
return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
}
smallChildren() {
const { children } = this.props;
return React.Children.toArray(children).every((c: ReactElement<any>) => c.props.className.includes('small'));
}
handleToggle(toggleValue) {
const { value, onChange } = this.props;
if (value && value === toggleValue) {
return;
}
onChange(toggleValue);
}
render() {
const { value, label } = this.props;
const values = this.getValues();
const selectedValue = value || values[0];
const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`;
return (
<div className="gf-form">
<div className="toggle-button-group">
{label && <label className={labelClassName}>{label}</label>}
{this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })}
</div>
</div>
);
}
}
interface ToggleButtonProps {
onChange?: (value) => void;
selected?: boolean;
value: any;
className?: string;
children: ReactNode;
title?: string;
}
export const ToggleButton: SFC<ToggleButtonProps> = ({
children,
selected,
className = '',
title = null,
value,
onChange,
}) => {
const handleChange = event => {
event.stopPropagation();
if (onChange) {
onChange(value);
}
};
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
return (
<button className={btnClassName} onClick={handleChange} title={title}>
<span>{children}</span>
</button>
);
};
@@ -14,7 +14,7 @@ ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"]
background: #555651\
}\
.gf-code-dark {\
background-color: #111;\
background-color: #09090b;\
color: #e0e0e0\
}\
.gf-code-dark .ace_cursor {\
@@ -44,7 +44,7 @@ export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
const drop = new Drop({
target: this.pickerElem,
content: dropContentElem,
position: 'top center',
position: 'bottom center',
classes: 'drop-popover',
openOn: 'hover',
hoverCloseDelay: 200,
@@ -41,7 +41,7 @@
</a>
</div>
<tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onSelect="ctrl.onTagSelect">
<tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onChange="ctrl.onTagFiltersChanged">
</tag-filter>
</div>
+6 -8
View File
@@ -25,8 +25,6 @@ export class SearchCtrl {
appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
this.initialFolderFilterTitle = 'All';
this.getTags = this.getTags.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.isEditor = contextSrv.isEditor;
this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
}
@@ -162,7 +160,7 @@ export class SearchCtrl {
const localSearchId = this.currentSearchId;
const query = {
...this.query,
tag: this.query.tag.map(i => i.value),
tag: this.query.tag,
};
return this.searchSrv.search(query).then(results => {
@@ -195,14 +193,14 @@ export class SearchCtrl {
evt.preventDefault();
}
getTags() {
getTags = () => {
return this.searchSrv.getDashboardTags();
}
};
onTagSelect(newTags) {
this.query.tag = newTags;
onTagFiltersChanged = (tags: string[]) => {
this.query.tag = tags;
this.search();
}
};
clearSearchFilter() {
this.query.tag = [];
@@ -15,7 +15,7 @@ const TopSectionItem: SFC<Props> = props => {
{link.img && <img src={link.img} />}
</span>
</a>
{link.children && <SideMenuDropDown link={link} />}
<SideMenuDropDown link={link} />
</div>
);
};
@@ -13,5 +13,8 @@ exports[`Render should render component 1`] = `
<i />
</span>
</a>
<SideMenuDropDown
link={Object {}}
/>
</div>
`;
+5 -1
View File
@@ -54,7 +54,11 @@ export class Settings {
}
}
const bootData = (window as any).grafanaBootData || { settings: {} };
const bootData = (window as any).grafanaBootData || {
settings: {},
user: {},
};
const options = bootData.settings;
options.bootData = bootData;
+138 -20
View File
@@ -1,6 +1,6 @@
import _ from 'lodash';
import { TimeSeries } from 'app/core/core';
import colors from 'app/core/utils/colors';
import colors, { getThemeColor } from 'app/core/utils/colors';
export enum LogLevel {
crit = 'critical',
@@ -22,7 +22,7 @@ export const LogLevelColor = {
[LogLevel.info]: colors[0],
[LogLevel.debug]: colors[5],
[LogLevel.trace]: colors[2],
[LogLevel.unkown]: '#ddd',
[LogLevel.unkown]: getThemeColor('#8e8e8e', '#dde4ed'),
};
export interface LogSearchMatch {
@@ -45,6 +45,13 @@ export interface LogRow {
uniqueLabels?: LogsStreamLabels;
}
export interface LogsLabelStat {
active?: boolean;
count: number;
proportion: number;
value: string;
}
export enum LogsMetaKind {
Number,
String,
@@ -81,6 +88,13 @@ export interface LogsStreamLabels {
[key: string]: string;
}
export enum LogsDedupDescription {
none = 'No de-duplication',
exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.',
signature = 'De-duplication of successive lines that have identical punctuation and whitespace.',
}
export enum LogsDedupStrategy {
none = 'none',
exact = 'exact',
@@ -88,6 +102,73 @@ export enum LogsDedupStrategy {
signature = 'signature',
}
export interface LogsParser {
/**
* Value-agnostic matcher for a field label.
* Used to filter rows, and first capture group contains the value.
*/
buildMatcher: (label: string) => RegExp;
/**
* Regex to find a field in the log line.
* First capture group contains the label value, second capture group the value.
*/
fieldRegex: RegExp;
/**
* Function to verify if this is a valid parser for the given line.
* The parser accepts the line unless it returns undefined.
*/
test: (line: string) => any;
}
export const LogsParsers: { [name: string]: LogsParser } = {
JSON: {
buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"([^"]*)"`),
fieldRegex: /"(\w+)"\s*:\s*"([^"]*)"/,
test: line => {
try {
return JSON.parse(line);
} catch (error) {}
},
},
logfmt: {
buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`),
fieldRegex: /(?:^|\s)(\w+)=("[^"]*"|\S+)/,
test: line => LogsParsers.logfmt.fieldRegex.test(line),
},
};
export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] {
// Consider only rows that satisfy the matcher
const rowsWithField = rows.filter(row => extractor.test(row.entry));
const rowCount = rowsWithField.length;
// Get field value counts for eligible rows
const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
.reverse()
.value();
return sortedCounts;
}
export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] {
// Consider only rows that have the given label
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
const rowCount = rowsWithLabel.length;
// Get label value counts for eligible rows
const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
.reverse()
.value();
return sortedCounts;
}
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean {
switch (strategy) {
@@ -128,6 +209,19 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
};
}
export function getParser(line: string): LogsParser {
let parser;
try {
if (LogsParsers.JSON.test(line)) {
parser = LogsParsers.JSON;
}
} catch (error) {}
if (!parser && LogsParsers.logfmt.test(line)) {
parser = LogsParsers.logfmt;
}
return parser;
}
export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>): LogsModel {
if (hiddenLogLevels.size === 0) {
return logs;
@@ -147,31 +241,55 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
}
export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix.
// intervalMs = intervalMs * 10;
// Graph time series by log level
const seriesByLevel = {};
rows.forEach(row => {
if (!seriesByLevel[row.logLevel]) {
seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel };
const bucketSize = intervalMs * 10;
const seriesList = [];
for (const row of rows) {
let series = seriesByLevel[row.logLevel];
if (!series) {
seriesByLevel[row.logLevel] = series = {
lastTs: null,
datapoints: [],
alias: row.logLevel,
color: LogLevelColor[row.logLevel],
};
seriesList.push(series);
}
const levelSeries = seriesByLevel[row.logLevel];
// align time to bucket size
const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize;
// Bucket to nearest minute
const time = Math.round(row.timeEpochMs / intervalMs / 10) * intervalMs * 10;
// Entry for time
if (time === levelSeries.lastTs) {
levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++;
if (time === series.lastTs) {
series.datapoints[series.datapoints.length - 1][0]++;
} else {
levelSeries.datapoints.push([1, time]);
levelSeries.lastTs = time;
series.datapoints.push([1, time]);
series.lastTs = time;
}
});
return Object.keys(seriesByLevel).reduce((acc, level) => {
if (seriesByLevel[level]) {
const gs = new TimeSeries(seriesByLevel[level]);
gs.setColor(LogLevelColor[level]);
acc.push(gs);
// add zero to other levels to aid stacking so each level series has same number of points
for (const other of seriesList) {
if (other !== series && other.lastTs !== time) {
other.datapoints.push([0, time]);
other.lastTs = time;
}
}
return acc;
}, []);
}
return seriesList.map(series => {
series.datapoints.sort((a, b) => {
return a[1] - b[1];
});
return new TimeSeries(series);
});
}
+173 -1
View File
@@ -1,4 +1,12 @@
import { dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model';
import {
calculateFieldStats,
calculateLogsLabelStats,
dedupLogRows,
getParser,
LogsDedupStrategy,
LogsModel,
LogsParsers,
} from '../logs_model';
describe('dedupLogRows()', () => {
test('should return rows as is when dedup is set to none', () => {
@@ -106,3 +114,167 @@ describe('dedupLogRows()', () => {
]);
});
});
describe('calculateFieldStats()', () => {
test('should return no stats for empty rows', () => {
expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]);
});
test('should return no stats if extractor does not match', () => {
const rows = [
{
entry: 'foo=bar',
},
];
expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]);
});
test('should return stats for found field', () => {
const rows = [
{
entry: 'foo="42 + 1"',
},
{
entry: 'foo=503 baz=foo',
},
{
entry: 'foo="42 + 1"',
},
{
entry: 't=2018-12-05T07:44:59+0000 foo=503',
},
];
expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([
{
value: '"42 + 1"',
count: 2,
},
{
value: '503',
count: 2,
},
]);
});
});
describe('calculateLogsLabelStats()', () => {
test('should return no stats for empty rows', () => {
expect(calculateLogsLabelStats([], '')).toEqual([]);
});
test('should return no stats of label is not found', () => {
const rows = [
{
entry: 'foo 1',
labels: {
foo: 'bar',
},
},
];
expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]);
});
test('should return stats for found labels', () => {
const rows = [
{
entry: 'foo 1',
labels: {
foo: 'bar',
},
},
{
entry: 'foo 0',
labels: {
foo: 'xxx',
},
},
{
entry: 'foo 2',
labels: {
foo: 'bar',
},
},
];
expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([
{
value: 'bar',
count: 2,
},
{
value: 'xxx',
count: 1,
},
]);
});
});
describe('getParser()', () => {
test('should return no parser on empty line', () => {
expect(getParser('')).toBeUndefined();
});
test('should return no parser on unknown line pattern', () => {
expect(getParser('To Be or not to be')).toBeUndefined();
});
test('should return logfmt parser on key value patterns', () => {
expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt);
});
test('should return JSON parser on JSON log lines', () => {
// TODO implement other JSON value types than string
expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON);
});
});
describe('LogsParsers', () => {
describe('logfmt', () => {
const parser = LogsParsers.logfmt;
test('should detect format', () => {
expect(parser.test('foo')).toBeFalsy();
expect(parser.test('foo=bar')).toBeTruthy();
});
test('should have a valid fieldRegex', () => {
const match = 'foo=bar'.match(parser.fieldRegex);
expect(match).toBeDefined();
expect(match[1]).toBe('foo');
expect(match[2]).toBe('bar');
});
test('should build a valid value matcher', () => {
const matcher = parser.buildMatcher('foo');
const match = 'foo=bar'.match(matcher);
expect(match).toBeDefined();
expect(match[1]).toBe('bar');
});
});
describe('JSON', () => {
const parser = LogsParsers.JSON;
test('should detect format', () => {
expect(parser.test('foo')).toBeFalsy();
expect(parser.test('{"foo":"bar"}')).toBeTruthy();
});
test('should have a valid fieldRegex', () => {
const match = '{"foo":"bar"}'.match(parser.fieldRegex);
expect(match).toBeDefined();
expect(match[1]).toBe('foo');
expect(match[2]).toBe('bar');
});
test('should build a valid value matcher', () => {
const matcher = parser.buildMatcher('foo');
const match = '{"foo":"bar"}'.match(matcher);
expect(match).toBeDefined();
expect(match[1]).toBe('bar');
});
});
});
+5
View File
@@ -1,5 +1,6 @@
import _ from 'lodash';
import tinycolor from 'tinycolor2';
import config from 'app/core/config';
export const PALETTE_ROWS = 4;
export const PALETTE_COLUMNS = 14;
@@ -90,5 +91,9 @@ export function hslToHex(color) {
return tinycolor(color).toHexString();
}
export function getThemeColor(dark: string, light: string): string {
return config.bootData.user.lightTheme ? light : dark;
}
export let sortedColors = sortColorsByHue(colors);
export default colors;
+17 -14
View File
@@ -1,15 +1,15 @@
import _ from 'lodash';
import { renderUrl } from 'app/core/utils/url';
import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
import { DataQuery, RawTimeRange } from 'app/types/series';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import TimeSeries from 'app/core/time_series2';
import { parse as parseDate } from 'app/core/utils/datemath';
import store from 'app/core/store';
import colors from 'app/core/utils/colors';
import { parse as parseDate } from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series';
export const DEFAULT_RANGE = {
from: 'now-6h',
@@ -130,10 +130,15 @@ export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
}
/**
* A target is non-empty when it has keys other than refId and key.
* A target is non-empty when it has keys (with non-empty values) other than refId and key.
*/
export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
return queries.some(query => Object.keys(query).length > 2);
return queries.some(
query =>
Object.keys(query)
.map(k => query[k])
.filter(v => v).length > 2
);
}
export function calculateResultsFromQueryTransactions(
@@ -165,18 +170,16 @@ export function calculateResultsFromQueryTransactions(
};
}
export function getIntervals(
range: RawTimeRange,
datasource,
resolution: number
): { interval: string; intervalMs: number } {
export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
if (!datasource || !resolution) {
return { interval: '1s', intervalMs: 1000 };
}
const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
}
+2 -2
View File
@@ -590,8 +590,8 @@ kbn.valueFormats.flowcms = kbn.formatBuilders.fixedUnit('cms');
kbn.valueFormats.flowcfs = kbn.formatBuilders.fixedUnit('cfs');
kbn.valueFormats.flowcfm = kbn.formatBuilders.fixedUnit('cfm');
kbn.valueFormats.litreh = kbn.formatBuilders.fixedUnit('l/h');
kbn.valueFormats.flowlpm = kbn.formatBuilders.decimalSIPrefix('l/min');
kbn.valueFormats.flowmlpm = kbn.formatBuilders.decimalSIPrefix('mL/min', -1);
kbn.valueFormats.flowlpm = kbn.formatBuilders.fixedUnit('l/min');
kbn.valueFormats.flowmlpm = kbn.formatBuilders.fixedUnit('mL/min');
// Angle
kbn.valueFormats.degree = kbn.formatBuilders.fixedUnit('°');
+16 -5
View File
@@ -16,9 +16,20 @@ describe('findMatchesInText()', () => {
expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
});
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'foo', end: 8 },
{ length: 3, start: 9, text: 'bar', end: 12 },
]);
test('should find all matches for a complete regex', () => {
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'foo', end: 8 },
{ length: 3, start: 9, text: 'bar', end: 12 },
]);
});
test('not fail on incomplete regex', () => {
expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'foo', end: 8 },
]);
expect(findMatchesInText('foo foo bar', '(')).toEqual([]);
expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]);
});
});
+22 -10
View File
@@ -8,6 +8,10 @@ export function findHighlightChunksInText({ searchWords, textToHighlight }) {
return findMatchesInText(textToHighlight, searchWords.join(' '));
}
const cleanNeedle = (needle: string): string => {
return needle.replace(/[[{(][\w,.-?:*+]+$/, '');
};
/**
* Returns a list of substring regexp matches.
*/
@@ -16,17 +20,25 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
if (!haystack || !needle) {
return [];
}
const regexp = new RegExp(`(?:${needle})`, 'g');
const matches = [];
let match = regexp.exec(haystack);
while (match) {
matches.push({
text: match[0],
start: match.index,
length: match[0].length,
end: match.index + match[0].length,
});
match = regexp.exec(haystack);
const cleaned = cleanNeedle(needle);
let regexp;
try {
regexp = new RegExp(`(?:${cleaned})`, 'g');
} catch (error) {
return matches;
}
haystack.replace(regexp, (substring, ...rest) => {
if (substring) {
const offset = rest[rest.length - 2];
matches.push({
text: substring,
start: offset,
length: substring.length,
end: offset + substring.length,
});
}
return '';
});
return matches;
}
@@ -223,6 +223,8 @@ export class DashboardModel {
}
panelInitialized(panel: PanelModel) {
panel.initialized();
if (!this.otherPanelInFullscreen(panel)) {
panel.refresh();
}
+1 -1
View File
@@ -132,7 +132,7 @@ export class PanelModel {
}
}
panelInitialized() {
initialized() {
this.events.emit('panel-initialized');
}
+49 -9
View File
@@ -38,7 +38,7 @@ import Graph from './Graph';
import Logs from './Logs';
import Table from './Table';
import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker';
import TimePicker, { parseTime } from './TimePicker';
interface ExploreProps {
datasourceSrv: DatasourceSrv;
@@ -115,7 +115,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} else {
const { datasource, queries, range } = props.urlState as ExploreUrlState;
initialQueries = ensureQueries(queries);
const initialRange = range || { ...DEFAULT_RANGE };
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE };
// Millies step for helper bar charts
const initialGraphInterval = 15 * 1000;
this.state = {
@@ -253,6 +253,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceLoading: false,
datasourceName: datasource.name,
initialQueries: nextQueries,
logsHighlighterExpressions: undefined,
showingStartPage: Boolean(StartPage),
},
() => {
@@ -291,7 +292,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return qt;
});
return { initialQueries: nextQueries, queryTransactions: nextQueryTransactions };
return {
initialQueries: nextQueries,
logsHighlighterExpressions: undefined,
queryTransactions: nextQueryTransactions,
};
});
};
@@ -337,6 +342,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions: nextQueryTransactions,
};
}, this.onSubmit);
} else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) {
// Live preview of log search matches. Can only work on single row query for now
this.updateLogsHighlights(value);
}
};
@@ -351,6 +359,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
};
onClickClear = () => {
this.onStopScanning();
this.modifiedQueries = ensureQueries();
this.setState(
prevState => ({
@@ -528,6 +537,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return {
...results,
initialQueries: nextQueries,
logsHighlighterExpressions: undefined,
queryTransactions: nextQueryTransactions,
};
},
@@ -644,6 +654,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
...results,
queryTransactions: nextQueryTransactions,
showingStartPage: false,
graphInterval: queryOptions.intervalMs,
};
});
@@ -664,7 +675,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
this.setState(state => {
const { history, queryTransactions, scanning } = state;
const { history, queryTransactions } = state;
let { scanning } = state;
// Transaction might have been discarded
const transaction = queryTransactions.find(qt => qt.id === transactionId);
@@ -701,15 +713,21 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const nextHistory = updateHistory(history, datasourceId, queries);
// Keep scanning for results if this was the last scanning transaction
if (_.size(result) === 0 && scanning) {
const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
if (!other) {
this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
if (scanning) {
if (_.size(result) === 0) {
const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
if (!other) {
this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
}
} else {
// We can stop scanning if we have a result
scanning = false;
}
}
return {
...results,
scanning,
history: nextHistory,
queryTransactions: nextQueryTransactions,
};
@@ -725,7 +743,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
console.error(response);
let error: string | JSX.Element = response;
let error: string | JSX.Element;
if (response.data) {
if (typeof response.data === 'string') {
error = response.data;
@@ -742,6 +760,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} else {
throw new Error('Could not handle error response');
}
} else if (response.message) {
error = response.message;
} else if (typeof response === 'string') {
error = response;
} else {
error = 'Unknown error during query transaction. Please check JS console logs.';
}
this.setState(state => {
@@ -771,6 +795,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
const queries = [...this.modifiedQueries];
if (!hasNonEmptyQuery(queries)) {
this.setState({
queryTransactions: [],
});
return;
}
const { datasource } = this.state;
@@ -790,6 +817,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
}
updateLogsHighlights = _.debounce((value: DataQuery, index: number) => {
this.setState(state => {
const { datasource } = state;
if (datasource.getHighlighterExpression) {
const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)];
return { logsHighlighterExpressions };
}
return null;
});
}, 500);
cloneState(): ExploreState {
// Copy state, but copy queries including modifications
return {
@@ -816,6 +854,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
graphResult,
history,
initialQueries,
logsHighlighterExpressions,
logsResult,
queryTransactions,
range,
@@ -960,6 +999,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
<Logs
data={logsResult}
key={logsResult.id}
highlighterExpressions={logsHighlighterExpressions}
loading={logsLoading}
position={position}
onChangeTime={this.onChangeTime}
+148
View File
@@ -0,0 +1,148 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
import classnames from 'classnames';
import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model';
function StatsRow({ active, count, proportion, value }: LogsLabelStat) {
const percent = `${Math.round(proportion * 100)}%`;
const barStyle = { width: percent };
const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
return (
<div className={className}>
<div className="logs-stats-row__label">
<div className="logs-stats-row__value">{value}</div>
<div className="logs-stats-row__count">{count}</div>
<div className="logs-stats-row__percent">{percent}</div>
</div>
<div className="logs-stats-row__bar">
<div className="logs-stats-row__innerbar" style={barStyle} />
</div>
</div>
);
}
const STATS_ROW_LIMIT = 5;
export class Stats extends PureComponent<{
stats: LogsLabelStat[];
label: string;
value: string;
rowCount: number;
onClickClose: () => void;
}> {
render() {
const { label, rowCount, stats, value, onClickClose } = this.props;
const topRows = stats.slice(0, STATS_ROW_LIMIT);
let activeRow = topRows.find(row => row.value === value);
let otherRows = stats.slice(STATS_ROW_LIMIT);
const insertActiveRow = !activeRow;
// Remove active row from other to show extra
if (insertActiveRow) {
activeRow = otherRows.find(row => row.value === value);
otherRows = otherRows.filter(row => row.value !== value);
}
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
const total = topCount + otherCount;
const otherProportion = otherCount / total;
return (
<div className="logs-stats">
<div className="logs-stats__header">
<span className="logs-stats__title">
{label}: {total} of {rowCount} rows have that label
</span>
<span className="logs-stats__close fa fa-remove" onClick={onClickClose} />
</div>
<div className="logs-stats__body">
{topRows.map(stat => <StatsRow key={stat.value} {...stat} active={stat.value === value} />)}
{insertActiveRow && activeRow && <StatsRow key={activeRow.value} {...activeRow} active />}
{otherCount > 0 && (
<StatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
)}
</div>
</div>
);
}
}
class Label extends PureComponent<
{
getRows?: () => LogRow[];
label: string;
plain?: boolean;
value: string;
onClickLabel?: (label: string, value: string) => void;
},
{ showStats: boolean; stats: LogsLabelStat[] }
> {
state = {
stats: null,
showStats: false,
};
onClickClose = () => {
this.setState({ showStats: false });
};
onClickLabel = () => {
const { onClickLabel, label, value } = this.props;
if (onClickLabel) {
onClickLabel(label, value);
}
};
onClickStats = () => {
this.setState(state => {
if (state.showStats) {
return { showStats: false, stats: null };
}
const allRows = this.props.getRows();
const stats = calculateLogsLabelStats(allRows, this.props.label);
return { showStats: true, stats };
});
};
render() {
const { getRows, label, plain, value } = this.props;
const { showStats, stats } = this.state;
const tooltip = `${label}: ${value}`;
return (
<span className="logs-label">
<span className="logs-label__value" title={tooltip}>
{value}
</span>
{!plain && (
<span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" />
)}
{!plain && getRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />}
{showStats && (
<span className="logs-label__stats">
<Stats
stats={stats}
rowCount={getRows().length}
label={label}
value={value}
onClickClose={this.onClickClose}
/>
</span>
)}
</span>
);
}
}
export default class LogLabels extends PureComponent<{
getRows?: () => LogRow[];
labels: LogsStreamLabels;
plain?: boolean;
onClickLabel?: (label: string, value: string) => void;
}> {
render() {
const { getRows, labels, onClickLabel, plain } = this.props;
return Object.keys(labels).map(key => (
<Label key={key} getRows={getRows} label={key} value={labels[key]} plain={plain} onClickLabel={onClickLabel} />
));
}
}
+256 -128
View File
@@ -1,28 +1,36 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import classnames from 'classnames';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange } from 'app/types/series';
import {
LogsDedupDescription,
LogsDedupStrategy,
LogsModel,
dedupLogRows,
filterLogLevels,
getParser,
LogLevel,
LogsStreamLabels,
LogsMetaKind,
LogsLabelStat,
LogsParser,
LogRow,
calculateFieldStats,
} from 'app/core/logs_model';
import { findHighlightChunksInText } from 'app/core/utils/text';
import { Switch } from 'app/core/components/Switch/Switch';
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
import Graph from './Graph';
import LogLabels, { Stats } from './LogLabels';
const PREVIEW_LIMIT = 100;
const graphOptions = {
series: {
stack: true,
bars: {
show: true,
lineWidth: 5,
@@ -35,106 +43,212 @@ const graphOptions = {
},
};
/**
* Renders a highlighted field.
* When hovering, a stats icon is shown.
*/
const FieldHighlight = onClick => props => {
return (
<span className={props.className} style={props.style}>
{props.children}
<span className="logs-row__field-highlight--icon fa fa-signal" onClick={() => onClick(props.children)} />
</span>
);
};
interface RowProps {
highlighterExpressions?: string[];
row: LogRow;
showDuplicates: boolean;
showLabels: boolean | null; // Tristate: null means auto
showLocalTime: boolean;
showUtc: boolean;
getRows: () => LogRow[];
onClickLabel?: (label: string, value: string) => void;
}
interface RowState {
fieldCount: number;
fieldLabel: string;
fieldStats: LogsLabelStat[];
fieldValue: string;
parsed: boolean;
parser: LogsParser;
parsedFieldHighlights: string[];
showFieldStats: boolean;
}
/**
* Renders a log line.
*
* When user hovers over it for a certain time, it lazily parses the log line.
* Once a parser is found, it will determine fields, that will be highlighted.
* When the user requests stats for a field, they will be calculated and rendered below the row.
*/
class Row extends PureComponent<RowProps, RowState> {
mouseMessageTimer: NodeJS.Timer;
state = {
fieldCount: 0,
fieldLabel: null,
fieldStats: null,
fieldValue: null,
parsed: false,
parser: null,
parsedFieldHighlights: [],
showFieldStats: false,
};
componentWillUnmount() {
clearTimeout(this.mouseMessageTimer);
}
onClickClose = () => {
this.setState({ showFieldStats: false });
};
onClickHighlight = (fieldText: string) => {
const { getRows } = this.props;
const { parser } = this.state;
const fieldMatch = fieldText.match(parser.fieldRegex);
if (fieldMatch) {
const allRows = getRows();
// Build value-agnostic row matcher based on the field label
const fieldLabel = fieldMatch[1];
const fieldValue = fieldMatch[2];
const matcher = parser.buildMatcher(fieldLabel);
const fieldStats = calculateFieldStats(allRows, matcher);
const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
}
};
onMouseOverMessage = () => {
// Don't parse right away, user might move along
this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
};
onMouseOutMessage = () => {
clearTimeout(this.mouseMessageTimer);
this.setState({ parsed: false });
};
parseMessage = () => {
if (!this.state.parsed) {
const { row } = this.props;
const parser = getParser(row.entry);
if (parser) {
// Use parser to highlight detected fields
const parsedFieldHighlights = [];
this.props.row.entry.replace(new RegExp(parser.fieldRegex, 'g'), substring => {
parsedFieldHighlights.push(substring.trim());
return '';
});
this.setState({ parsedFieldHighlights, parsed: true, parser });
}
}
};
render() {
const {
getRows,
highlighterExpressions,
onClickLabel,
row,
showDuplicates,
showLabels,
showLocalTime,
showUtc,
} = this.props;
const {
fieldCount,
fieldLabel,
fieldStats,
fieldValue,
parsed,
parsedFieldHighlights,
showFieldStats,
} = this.state;
const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
const needsHighlighter = highlights && highlights.length > 0;
const highlightClassName = classnames('logs-row__match-highlight', {
'logs-row__match-highlight--preview': previewHighlights,
});
return (
<div className="logs-row">
{showDuplicates && (
<div className="logs-row__duplicates">{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}</div>
)}
<div className={row.logLevel ? `logs-row__level logs-row__level--${row.logLevel}` : ''} />
{showUtc && (
<div className="logs-row__time" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
{row.timestamp}
</div>
)}
{showLocalTime && (
<div className="logs-row__time" title={`${row.timestamp} (${row.timeFromNow})`}>
{row.timeLocal}
</div>
)}
{showLabels && (
<div className="logs-row__labels">
<LogLabels getRows={getRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
</div>
)}
<div className="logs-row__message" onMouseEnter={this.onMouseOverMessage} onMouseLeave={this.onMouseOutMessage}>
{parsed && (
<Highlighter
autoEscape
highlightTag={FieldHighlight(this.onClickHighlight)}
textToHighlight={row.entry}
searchWords={parsedFieldHighlights}
highlightClassName="logs-row__field-highlight"
/>
)}
{!parsed &&
needsHighlighter && (
<Highlighter
textToHighlight={row.entry}
searchWords={highlights}
findChunks={findHighlightChunksInText}
highlightClassName={highlightClassName}
/>
)}
{!parsed && !needsHighlighter && row.entry}
{showFieldStats && (
<div className="logs-row__stats">
<Stats
stats={fieldStats}
label={fieldLabel}
value={fieldValue}
onClickClose={this.onClickClose}
rowCount={fieldCount}
/>
</div>
)}
</div>
</div>
);
}
}
function renderMetaItem(value: any, kind: LogsMetaKind) {
if (kind === LogsMetaKind.LabelsMap) {
return (
<span className="logs-meta-item__value-labels">
<Labels labels={value} />
<span className="logs-meta-item__labels">
<LogLabels labels={value} plain />
</span>
);
}
return value;
}
class Label extends PureComponent<{
label: string;
value: string;
onClickLabel?: (label: string, value: string) => void;
}> {
onClickLabel = () => {
const { onClickLabel, label, value } = this.props;
if (onClickLabel) {
onClickLabel(label, value);
}
};
render() {
const { label, value } = this.props;
const tooltip = `${label}: ${value}`;
return (
<span className="logs-label" title={tooltip} onClick={this.onClickLabel}>
{value}
</span>
);
}
}
class Labels extends PureComponent<{
labels: LogsStreamLabels;
onClickLabel?: (label: string, value: string) => void;
}> {
render() {
const { labels, onClickLabel } = this.props;
return Object.keys(labels).map(key => (
<Label key={key} label={key} value={labels[key]} onClickLabel={onClickLabel} />
));
}
}
interface RowProps {
row: LogRow;
showLabels: boolean | null; // Tristate: null means auto
showLocalTime: boolean;
showUtc: boolean;
onClickLabel?: (label: string, value: string) => void;
}
function Row({ onClickLabel, row, showLabels, showLocalTime, showUtc }: RowProps) {
const needsHighlighter = row.searchWords && row.searchWords.length > 0;
return (
<div className="logs-row">
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''}>
{row.duplicates > 0 && (
<div className="logs-row-level__duplicates" title={`${row.duplicates} duplicates`}>
{Array.apply(null, { length: row.duplicates }).map((bogus, index) => (
<div className="logs-row-level__duplicate" key={`${index}`} />
))}
</div>
)}
</div>
{showUtc && (
<div className="logs-row-time" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
{row.timestamp}
</div>
)}
{showLocalTime && (
<div className="logs-row-time" title={`${row.timestamp} (${row.timeFromNow})`}>
{row.timeLocal}
</div>
)}
{showLabels && (
<div className="logs-row-labels">
<Labels labels={row.uniqueLabels} onClickLabel={onClickLabel} />
</div>
)}
<div className="logs-row-message">
{needsHighlighter ? (
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
) : (
row.entry
)}
</div>
</div>
);
}
interface LogsProps {
className?: string;
data: LogsModel;
highlighterExpressions: string[];
loading: boolean;
position: string;
range?: RawTimeRange;
@@ -239,10 +353,20 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
};
render() {
const { className = '', data, loading = false, onClickLabel, position, range, scanning, scanRange } = this.props;
const {
data,
highlighterExpressions,
loading = false,
onClickLabel,
position,
range,
scanning,
scanRange,
} = this.props;
const { dedup, deferLogs, hiddenLogLevels, renderAll, showLocalTime, showUtc } = this.state;
let { showLabels } = this.state;
const hasData = data && data.rows && data.rows.length > 0;
const showDuplicates = dedup !== LogsDedupStrategy.none;
// Filtering
const filteredData = filterLogLevels(data, hiddenLogLevels);
@@ -258,8 +382,9 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
}
// Staged rendering
const firstRows = dedupedData.rows.slice(0, PREVIEW_LIMIT);
const lastRows = dedupedData.rows.slice(PREVIEW_LIMIT);
const processedRows = dedupedData.rows;
const firstRows = processedRows.slice(0, PREVIEW_LIMIT);
const lastRows = processedRows.slice(PREVIEW_LIMIT);
// Check for labels
if (showLabels === null) {
@@ -272,9 +397,12 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = () => processedRows;
return (
<div className={`${className} logs`}>
<div className="logs-graph">
<div className="logs-panel">
<div className="logs-panel-graph">
<Graph
data={data.series}
height="100px"
@@ -285,43 +413,37 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
userOptions={graphOptions}
/>
</div>
<div className="logs-options">
<div className="logs-controls">
<div className="logs-panel-options">
<div className="logs-panel-controls">
<Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small />
<Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} small />
<Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} small />
<Switch
label="Dedup: off"
checked={dedup === LogsDedupStrategy.none}
onChange={() => this.onChangeDedup(LogsDedupStrategy.none)}
small
/>
<Switch
label="Dedup: exact"
checked={dedup === LogsDedupStrategy.exact}
onChange={() => this.onChangeDedup(LogsDedupStrategy.exact)}
small
/>
<Switch
label="Dedup: numbers"
checked={dedup === LogsDedupStrategy.numbers}
onChange={() => this.onChangeDedup(LogsDedupStrategy.numbers)}
small
/>
<Switch
label="Dedup: signature"
checked={dedup === LogsDedupStrategy.signature}
onChange={() => this.onChangeDedup(LogsDedupStrategy.signature)}
small
<ToggleButtonGroup
label="Dedup"
onChange={this.onChangeDedup}
value={dedup}
render={({ selectedValue, onChange }) =>
Object.keys(LogsDedupStrategy).map((dedupType, i) => (
<ToggleButton
className="btn-small"
key={i}
value={dedupType}
onChange={onChange}
title={LogsDedupDescription[dedupType] || null}
selected={selectedValue === dedupType}
>
{dedupType}
</ToggleButton>
))
}
/>
{hasData &&
meta && (
<div className="logs-meta">
<div className="logs-panel-meta">
{meta.map(item => (
<div className="logs-meta-item" key={item.label}>
<span className="logs-meta-item__label">{item.label}:</span>
<span className="logs-meta-item__value">{renderMetaItem(item.value, item.kind)}</span>
<div className="logs-panel-meta__item" key={item.label}>
<span className="logs-panel-meta__label">{item.label}:</span>
<span className="logs-panel-meta__value">{renderMetaItem(item.value, item.kind)}</span>
</div>
))}
</div>
@@ -329,13 +451,17 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
</div>
</div>
<div className="logs-entries">
<div className="logs-rows">
{hasData &&
!deferLogs &&
// Only inject highlighterExpression in the first set for performance reasons
firstRows.map(row => (
<Row
key={row.key + row.duplicates}
getRows={getRows}
highlighterExpressions={highlighterExpressions}
row={row}
showDuplicates={showDuplicates}
showLabels={showLabels}
showLocalTime={showLocalTime}
showUtc={showUtc}
@@ -348,7 +474,9 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
lastRows.map(row => (
<Row
key={row.key + row.duplicates}
getRows={getRows}
row={row}
showDuplicates={showDuplicates}
showLabels={showLabels}
showLocalTime={showLocalTime}
showUtc={showUtc}
@@ -360,7 +488,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
{!loading &&
!hasData &&
!scanning && (
<div className="logs-nodata">
<div className="logs-panel-nodata">
No logs found.
<a className="link" onClick={this.onClickScan}>
Scan for older logs
@@ -369,7 +497,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
)}
{scanning && (
<div className="logs-nodata">
<div className="logs-panel-nodata">
<span>{scanText}</span>
<a className="link" onClick={this.onClickStopScan}>
Stop scan
+18 -5
View File
@@ -15,11 +15,14 @@ export const DEFAULT_RANGE = {
* Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
* @param value Epoch or relative time
*/
export function parseTime(value: string, isUtc = false): string {
export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment {
if (moment.isMoment(value)) {
if (ensureString) {
return value.format(DATE_FORMAT);
}
return value;
}
if (value.indexOf('now') !== -1) {
if ((value as string).indexOf('now') !== -1) {
return value;
}
let time: any = value;
@@ -50,6 +53,16 @@ interface TimePickerState {
toRaw: string;
}
/**
* TimePicker with dropdown menu for relative dates.
*
* Initialize with a range that is either based on relative time strings,
* or on Moment objects.
* Internally the component needs to keep a string representation in `fromRaw`
* and `toRaw` for the controlled inputs.
* When a time is picked, `onChangeTime` is called with the new range that
* is again based on relative time strings or Moment objects.
*/
export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
dropdownEl: any;
@@ -75,9 +88,9 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
const from = props.range ? props.range.from : DEFAULT_RANGE.from;
const to = props.range ? props.range.to : DEFAULT_RANGE.to;
// Ensure internal format
const fromRaw = parseTime(from, props.isUtc);
const toRaw = parseTime(to, props.isUtc);
// Ensure internal string format
const fromRaw = parseTime(from, props.isUtc, true);
const toRaw = parseTime(to, props.isUtc, true);
const range = {
from: fromRaw,
to: toRaw,
+11 -4
View File
@@ -89,10 +89,17 @@ export class MetricsTabCtrl {
target.datasource = config.defaultDatasource;
}
});
} else if (this.datasourceInstance && this.datasourceInstance.meta.mixed) {
_.each(this.panel.targets, target => {
delete target.datasource;
});
} else if (this.datasourceInstance) {
// if switching from mixed
if (this.datasourceInstance.meta.mixed) {
_.each(this.panel.targets, target => {
delete target.datasource;
});
} else if (this.datasourceInstance.meta.id !== datasource.meta.id) {
// we are changing data source type, clear queries
this.panel.targets = [{ refId: 'A' }];
this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
}
}
this.datasourceInstance = datasource;
@@ -1,5 +1,4 @@
<div class="panel panel--solo" ng-if="panel" style="width: 100%">
<div class="panel-solo" ng-if="panel">
<plugin-component type="panel">
</plugin-component>
</div>
<div class="clearfix"></div>
@@ -4,7 +4,7 @@ import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/modul
import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module';
import * as grafanaPlugin from 'app/plugins/datasource/grafana/module';
import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module';
import * as loggingPlugin from 'app/plugins/datasource/logging/module';
import * as lokiPlugin from 'app/plugins/datasource/loki/module';
import * as mixedPlugin from 'app/plugins/datasource/mixed/module';
import * as mysqlPlugin from 'app/plugins/datasource/mysql/module';
import * as postgresPlugin from 'app/plugins/datasource/postgres/module';
@@ -33,7 +33,7 @@ const builtInPlugins = {
'app/plugins/datasource/opentsdb/module': opentsdbPlugin,
'app/plugins/datasource/grafana/module': grafanaPlugin,
'app/plugins/datasource/influxdb/module': influxdbPlugin,
'app/plugins/datasource/logging/module': loggingPlugin,
'app/plugins/datasource/loki/module': lokiPlugin,
'app/plugins/datasource/mixed/module': mixedPlugin,
'app/plugins/datasource/mysql/module': mysqlPlugin,
'app/plugins/datasource/postgres/module': postgresPlugin,
+1 -1
View File
@@ -1,6 +1,6 @@
import coreModule from 'app/core/core_module';
export default class CreateTeamCtrl {
export class CreateTeamCtrl {
name: string;
email: string;
navModel: any;
+1 -1
View File
@@ -115,7 +115,7 @@ export class TeamMembers extends PureComponent<Props, State> {
</button>
<h5>Add Team Member</h5>
<div className="gf-form-inline">
<UserPicker onSelected={this.onUserSelected} className="width-30" />
<UserPicker onSelected={this.onUserSelected} className="min-width-30" />
{this.state.newTeamMember && (
<button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
Add to team
@@ -58,7 +58,7 @@ exports[`Render should render component 1`] = `
className="gf-form-inline"
>
<UserPicker
className="width-30"
className="min-width-30"
onSelected={[Function]}
/>
</div>
@@ -152,7 +152,7 @@ exports[`Render should render team members 1`] = `
className="gf-form-inline"
>
<UserPicker
className="width-30"
className="min-width-30"
onSelected={[Function]}
/>
</div>
@@ -372,7 +372,7 @@ exports[`Render should render team members when sync enabled 1`] = `
className="gf-form-inline"
>
<UserPicker
className="width-30"
className="min-width-30"
onSelected={[Function]}
/>
</div>
@@ -38,8 +38,9 @@ export class CustomVariable implements Variable {
}
updateOptions() {
// extract options in comma separated string
this.options = _.map(this.query.split(/[,]+/), text => {
// extract options in comma separated string (use backslash to escape wanted commas)
this.options = _.map(this.query.match(/(?:\\,|[^,])+/g), text => {
text = text.replace('\\,', ',');
return { text: text.trim(), value: text.trim() };
});
@@ -151,7 +151,7 @@
<h5 class="section-heading">Custom Options</h5>
<div class="gf-form">
<span class="gf-form-label width-14">Values separated by comma</span>
<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"
<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue, escaped\,value"
required></input>
</div>
</div>
@@ -493,15 +493,17 @@ describe('VariableSrv', function(this: any) {
scenario.setup(() => {
scenario.variableModel = {
type: 'custom',
query: 'hej, hop, asd',
query: 'hej, hop, asd, escaped\\,var',
name: 'test',
};
});
it('should update options array', () => {
expect(scenario.variable.options.length).toBe(3);
expect(scenario.variable.options.length).toBe(4);
expect(scenario.variable.options[0].text).toBe('hej');
expect(scenario.variable.options[1].value).toBe('hop');
expect(scenario.variable.options[2].value).toBe('asd');
expect(scenario.variable.options[3].value).toBe('escaped,var');
});
});
@@ -1,3 +0,0 @@
# Grafana Logging Datasource - Native Plugin
This is a **built in** datasource that allows you to connect to Grafana's logging service.
@@ -1,60 +0,0 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import LoggingCheatSheet from './LoggingCheatSheet';
const TAB_MENU_ITEMS = [
{
text: 'Start',
id: 'start',
icon: 'fa fa-rocket',
},
];
export default class LoggingStartPage extends PureComponent<any, { active: string }> {
state = {
active: 'start',
};
onClickTab = active => {
this.setState({ active });
};
render() {
const { active } = this.state;
const customCss = '';
return (
<div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
<nav>
<ul className={`gf-tabs ${customCss}`}>
{TAB_MENU_ITEMS.map((tab, idx) => {
const tabClasses = classNames({
'gf-tabs-link': true,
active: tab.id === active,
});
return (
<li className="gf-tabs-item" key={tab.id}>
<a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
})}
</ul>
</nav>
</div>
</div>
</div>
<div className="page-container page-body">
{active === 'start' && <LoggingCheatSheet onClickExample={this.props.onClickExample} />}
</div>
</div>
);
}
}
@@ -1,15 +0,0 @@
import Datasource from './datasource';
import LoggingStartPage from './components/LoggingStartPage';
import LoggingQueryField from './components/LoggingQueryField';
export class LoggingConfigCtrl {
static templateUrl = 'partials/config.html';
}
export {
Datasource,
LoggingConfigCtrl as ConfigCtrl,
LoggingQueryField as ExploreQueryField,
LoggingStartPage as ExploreStartPage,
};
@@ -0,0 +1,3 @@
# Loki Datasource - Native Plugin
This is a **built in** datasource that allows you to connect to the Loki logging service.
@@ -15,7 +15,7 @@ const CHEAT_SHEET_ITEMS = [
export default (props: any) => (
<div>
<h1>Logging Cheat Sheet</h1>
<h2>Loki Cheat Sheet</h2>
{CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item__title">{item.title}</div>
@@ -49,7 +49,7 @@ interface CascaderOption {
disabled?: boolean;
}
interface LoggingQueryFieldProps {
interface LokiQueryFieldProps {
datasource: any;
error?: string | JSX.Element;
hint?: any;
@@ -60,16 +60,16 @@ interface LoggingQueryFieldProps {
onQueryChange?: (value: DataQuery, override?: boolean) => void;
}
interface LoggingQueryFieldState {
interface LokiQueryFieldState {
logLabelOptions: any[];
syntaxLoaded: boolean;
}
class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, LoggingQueryFieldState> {
class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
plugins: any[];
languageProvider: any;
constructor(props: LoggingQueryFieldProps, context) {
constructor(props: LokiQueryFieldProps, context) {
super(props, context);
if (props.datasource.languageProvider) {
@@ -208,8 +208,8 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a Logging query"
portalOrigin="logging"
placeholder="Enter a Loki Log query"
portalOrigin="loki"
syntaxLoaded={syntaxLoaded}
/>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
@@ -229,4 +229,4 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
}
}
export default LoggingQueryField;
export default LokiQueryField;
@@ -0,0 +1,16 @@
import React, { PureComponent } from 'react';
import LokiCheatSheet from './LokiCheatSheet';
interface Props {
onClickExample: () => void;
}
export default class LokiStartPage extends PureComponent<Props> {
render() {
return (
<div className="grafana-info-box grafana-info-box--max-lg">
<LokiCheatSheet onClickExample={this.props.onClickExample} />
</div>
);
}
}
@@ -0,0 +1,98 @@
import LokiDatasource from './datasource';
describe('LokiDatasource', () => {
const instanceSettings = {
url: 'myloggingurl',
};
describe('when performing testDataSource', () => {
let ds;
let result;
describe('and call succeeds', () => {
beforeEach(async () => {
const backendSrv = {
async datasourceRequest() {
return Promise.resolve({
status: 200,
data: {
values: ['avalue'],
},
});
},
};
ds = new LokiDatasource(instanceSettings, backendSrv, {});
result = await ds.testDatasource();
});
it('should return successfully', () => {
expect(result.status).toBe('success');
});
});
describe('and call fails with 401 error', () => {
beforeEach(async () => {
const backendSrv = {
async datasourceRequest() {
return Promise.reject({
statusText: 'Unauthorized',
status: 401,
data: {
message: 'Unauthorized',
},
});
},
};
ds = new LokiDatasource(instanceSettings, backendSrv, {});
result = await ds.testDatasource();
});
it('should return error status and a detailed error message', () => {
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Unauthorized. 401. Unauthorized');
});
});
describe('and call fails with 404 error', () => {
beforeEach(async () => {
const backendSrv = {
async datasourceRequest() {
return Promise.reject({
statusText: 'Not found',
status: 404,
data: '404 page not found',
});
},
};
ds = new LokiDatasource(instanceSettings, backendSrv, {});
result = await ds.testDatasource();
});
it('should return error status and a detailed error message', () => {
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Not found. 404. 404 page not found');
});
});
describe('and call fails with 502 error', () => {
beforeEach(async () => {
const backendSrv = {
async datasourceRequest() {
return Promise.reject({
statusText: 'Bad Gateway',
status: 502,
data: '',
});
},
};
ds = new LokiDatasource(instanceSettings, backendSrv, {});
result = await ds.testDatasource();
});
it('should return error status and a detailed error message', () => {
expect(result.status).toEqual('error');
expect(result.message).toBe('Loki: Bad Gateway. 502');
});
});
});
});
@@ -27,7 +27,7 @@ function serializeParams(data: any) {
.join('&');
}
export default class LoggingDatasource {
export default class LokiDatasource {
languageProvider: LanguageProvider;
/** @ngInject */
@@ -94,7 +94,7 @@ export default class LoggingDatasource {
}
metadataRequest(url) {
// HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField
// HACK to get label values for {job=|}, will be replaced when implementing LokiQueryField
const apiUrl = url.replace('v1', 'prom');
return this._request(apiUrl, { silent: true }).then(res => {
const data = { data: { data: res.data.values || [] } };
@@ -117,6 +117,10 @@ export default class LoggingDatasource {
return { ...query, expr: expression };
}
getHighlighterExpression(query: DataQuery): string {
return parseQuery(query.expr).regexp;
}
getTime(date, roundUp) {
if (_.isString(date)) {
date = dateMath.parse(date, roundUp);
@@ -132,11 +136,28 @@ export default class LoggingDatasource {
}
return {
status: 'error',
message: 'Data source connected, but no labels received. Verify that logging is configured properly.',
message:
'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.',
};
})
.catch(err => {
return { status: 'error', message: err.message };
let message = 'Loki: ';
if (err.statusText) {
message += err.statusText;
} else {
message += 'Cannot connect to Loki';
}
if (err.status) {
message += `. ${err.status}`;
}
if (err.data && err.data.message) {
message += `. ${err.data.message}`;
} else if (err.data) {
message += `. ${err.data}`;
}
return { status: 'error', message: message };
});
}
}

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="200px" height="200px" viewBox="0 0 200 200" style="enable-background:new 0 0 200 200;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}
.st3{fill:url(#SVGID_4_);}
.st4{fill:url(#SVGID_5_);}
.st5{fill:url(#SVGID_6_);}
.st6{fill:url(#SVGID_7_);}
.st7{fill:url(#SVGID_8_);}
.st8{fill:url(#SVGID_9_);}
.st9{fill:url(#SVGID_10_);}
.st10{fill:url(#SVGID_11_);}
.st11{fill:url(#SVGID_12_);}
.st12{fill:url(#SVGID_13_);}
.st13{fill:url(#SVGID_14_);}
.st14{fill:url(#SVGID_15_);}
.st15{fill:url(#SVGID_16_);}
.st16{fill:url(#SVGID_17_);}
.st17{fill:url(#SVGID_18_);}
.st18{fill:url(#SVGID_19_);}
.st19{fill:url(#SVGID_20_);}
.st20{fill:url(#SVGID_21_);}
.st21{fill:url(#SVGID_22_);}
.st22{fill:url(#SVGID_23_);}
.st23{fill:url(#SVGID_24_);}
.st24{fill:url(#SVGID_25_);}
.st25{fill:url(#SVGID_26_);}
.st26{fill:url(#SVGID_27_);}
.st27{fill:url(#SVGID_28_);}
.st28{fill:url(#SVGID_29_);}
.st29{fill:url(#SVGID_30_);}
.st30{fill:url(#SVGID_31_);}
.st31{fill:url(#SVGID_32_);}
</style>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="135.0285" y1="238.7858" x2="135.0285" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st0" d="M179.5,130c-6.9-9.5-18.6-16.1-30.2-17.9l-38.1-4.6l0,22.4l34.7,4c5.8,0.9,12.3,4.3,15.8,9.1
c3.5,4.7,4.9,10.5,4,16.3c-1.7,10.8-11,18.5-21.6,18.5c-1.1,0-2.3-0.1-3.4-0.3l-37.9-4.7c-5.1,8-12.2,14.7-20.6,19.2l55.2,7.4
c2.3,0.4,4.6,0.5,6.8,0.5c21.3,0,40-15.6,43.4-37.3C189.3,151.1,186.4,139.5,179.5,130z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="56.0866" y1="238.7858" x2="56.0866" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st1" d="M90.5,171c1.3-1.6,2.4-3.3,3.5-5.1c1-1.7,1.9-3.4,2.7-5.2c2.3-5.3,3.5-11.2,3.5-17.3l0-4l0-5.6l0-5.6l0-22.4
l0-5.6l0-5.6L100,43.9C100,19.7,80.2,0,55.9,0S12,19.8,12,44.1l0.1,66.7c5.7-7.6,13.3-13.7,22.1-17.6L34.1,44
c0-12.1,9.8-21.9,21.8-21.9c12.1,0,21.9,9.8,21.9,21.8L78,91.2l0,5.6l0,5.6l0,22.4l0,5.6l0,5.6l0,7.5c0,5.2-1.8,9.9-4.8,13.7
c-1.5,1.9-3.3,3.5-5.3,4.8c-3.4,2.2-7.4,3.5-11.7,3.5c-0.7,0-1.4,0-2,0l-1.4-0.2c-10.8-1.7-18.6-11.1-18.5-21.8
c0-1.1,0.1-2.1,0.2-3.2c0.7-4.3,2.6-8.1,5.3-11.1c1.6-1.8,3.5-3.3,5.5-4.5c3.2-1.8,6.9-2.9,10.8-2.9c1.1,0,2.3,0.1,3.4,0.3l7.5,1.2
l0-22.4l-4-0.6c-2.3-0.4-4.6-0.5-6.8-0.5c-3.7,0-7.3,0.5-10.8,1.4c-1.9,0.5-3.7,1.1-5.5,1.8c-1.9,0.8-3.8,1.7-5.5,2.7
c-11.2,6.4-19.4,17.7-21.6,31.4c-0.4,2.4-0.5,4.9-0.5,7.3c0,1.2,0.1,2.3,0.2,3.5c0,0.3,0.1,0.6,0.1,0.9c0.1,1.1,0.3,2.3,0.5,3.4
c0.1,0.3,0.1,0.7,0.2,1c0.2,1.1,0.5,2.1,0.8,3.2c0.1,0.4,0.2,0.7,0.3,1.1c0.3,1,0.7,2,1.1,3c0.1,0.3,0.3,0.7,0.4,1
c0.4,1,0.9,1.9,1.4,2.9c0.2,0.3,0.3,0.6,0.5,0.9c0.5,1,1.1,1.9,1.7,2.8c0.2,0.2,0.3,0.5,0.5,0.7c0.7,1,1.4,1.9,2.1,2.9
c0.1,0.1,0.2,0.3,0.4,0.4c0.8,1,1.7,1.9,2.6,2.8c0.1,0.1,0.1,0.1,0.2,0.2c1,1,2,1.9,3,2.7c0,0,0.1,0,0.1,0.1
c1.1,0.9,2.2,1.7,3.3,2.5c0,0,0,0,0.1,0.1c1.1,0.8,2.3,1.5,3.5,2.1c0.1,0,0.1,0.1,0.2,0.1c1.1,0.6,2.3,1.2,3.5,1.7
c0.2,0.1,0.3,0.1,0.5,0.2c1.1,0.5,2.2,0.9,3.3,1.2c0.3,0.1,0.6,0.2,0.9,0.3c1,0.3,2.1,0.6,3.2,0.8c0.4,0.1,0.8,0.2,1.2,0.2
c2.7,0.5,5.5,0.8,8.3,0.8C70.1,187.5,82.4,181.1,90.5,171z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="26.7057" y1="238.7858" x2="26.7057" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st2" d="M28.2,177.5c-1-0.9-2-1.8-3-2.7"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="12.7354" y1="238.7858" x2="12.7354" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st3" d="M13,151.9c-0.2-1.1-0.4-2.2-0.5-3.4"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="14.8849" y1="238.7858" x2="14.8849" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st4" d="M15.4,160.1c-0.4-1-0.8-2-1.1-3"/>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="18.6023" y1="238.7858" x2="18.6023" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st5" d="M19.5,167.8c-0.6-0.9-1.2-1.9-1.7-2.8"/>
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="16.5561" y1="238.7858" x2="16.5561" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st6" d="M17.2,164c-0.5-0.9-1-1.9-1.4-2.9"/>
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="12.2907" y1="238.7858" x2="12.2907" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st7" d="M12.2,144.1c0,1.2,0.1,2.3,0.2,3.5"/>
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="23.7117" y1="238.7858" x2="23.7117" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st8" d="M25,174.6c-0.9-0.9-1.8-1.9-2.6-2.8"/>
<linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="20.9974" y1="238.7858" x2="20.9974" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st9" d="M22.1,171.3c-0.8-0.9-1.5-1.9-2.1-2.9"/>
<linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="13.6059" y1="238.7858" x2="13.6059" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st10" d="M14,156.1c-0.3-1-0.6-2.1-0.8-3.2"/>
<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="37.1094" y1="238.7858" x2="37.1094" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st11" d="M38.8,184c-1.2-0.5-2.3-1.1-3.5-1.7"/>
<linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="45.1619" y1="238.7858" x2="45.1619" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st12" d="M46.8,186.5c-1.1-0.2-2.1-0.5-3.2-0.8"/>
<linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="29.9487" y1="238.7858" x2="29.9487" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st13" d="M31.6,180c-1.1-0.8-2.2-1.6-3.3-2.5"/>
<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="41.0239" y1="238.7858" x2="41.0239" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st14" d="M42.7,185.4c-1.1-0.4-2.3-0.8-3.3-1.2"/>
<linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="33.4189" y1="238.7858" x2="33.4189" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st15" d="M35.2,182.2c-1.2-0.7-2.4-1.4-3.5-2.1"/>
<linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="53.9595" y1="238.7858" x2="53.9595" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st16" d="M54.1,154.2c-0.1,0-0.1,0-0.2-0.1"/>
<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="21.1405" y1="238.7858" x2="21.1405" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st17" d="M21.4,178.8c-0.2-0.2-0.3-0.3-0.5-0.5"/>
<linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="35.2646" y1="238.7858" x2="35.2646" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st18" d="M35.2,182.2c0.1,0,0.1,0.1,0.2,0.1"/>
<linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="39.0979" y1="238.7858" x2="39.0979" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st19" d="M39.3,184.2c-0.2-0.1-0.3-0.1-0.5-0.2"/>
<linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="31.6434" y1="238.7858" x2="31.6434" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st20" d="M31.7,180.1C31.7,180.1,31.6,180.1,31.7,180.1"/>
<linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="28.2485" y1="238.7858" x2="28.2485" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st21" d="M28.3,177.6C28.3,177.5,28.2,177.5,28.3,177.6"/>
<linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="43.1314" y1="238.7858" x2="43.1314" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st22" d="M43.6,185.7c-0.3-0.1-0.6-0.2-0.9-0.3"/>
<linearGradient id="SVGID_24_" gradientUnits="userSpaceOnUse" x1="47.3468" y1="238.7858" x2="47.3468" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st23" d="M46.8,186.5c0.4,0.1,0.8,0.2,1.2,0.2"/>
<linearGradient id="SVGID_25_" gradientUnits="userSpaceOnUse" x1="19.6975" y1="238.7858" x2="19.6975" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st24" d="M19.9,168.4c-0.2-0.2-0.3-0.5-0.5-0.7"/>
<linearGradient id="SVGID_26_" gradientUnits="userSpaceOnUse" x1="17.4923" y1="238.7858" x2="17.4923" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st25" d="M17.7,164.9c-0.2-0.3-0.3-0.6-0.5-0.9"/>
<linearGradient id="SVGID_27_" gradientUnits="userSpaceOnUse" x1="12.4278" y1="238.7858" x2="12.4278" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st26" d="M12.5,148.5c0-0.3-0.1-0.6-0.1-0.9"/>
<linearGradient id="SVGID_28_" gradientUnits="userSpaceOnUse" x1="13.0965" y1="238.7858" x2="13.0965" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st27" d="M13.2,152.9c-0.1-0.3-0.1-0.7-0.2-1"/>
<linearGradient id="SVGID_29_" gradientUnits="userSpaceOnUse" x1="14.1769" y1="238.7858" x2="14.1769" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st28" d="M14.3,157.1c-0.1-0.3-0.2-0.7-0.3-1.1"/>
<linearGradient id="SVGID_30_" gradientUnits="userSpaceOnUse" x1="15.6479" y1="238.7858" x2="15.6479" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st29" d="M15.9,161.1c-0.2-0.3-0.3-0.7-0.4-1"/>
<linearGradient id="SVGID_31_" gradientUnits="userSpaceOnUse" x1="22.2436" y1="238.7858" x2="22.2436" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st30" d="M22.4,171.7c-0.1-0.1-0.2-0.3-0.4-0.4"/>
<linearGradient id="SVGID_32_" gradientUnits="userSpaceOnUse" x1="25.1051" y1="238.7858" x2="25.1051" y2="2.4079">
<stop offset="0" style="stop-color:#FBED1D"/>
<stop offset="1" style="stop-color:#F05A2A"/>
</linearGradient>
<path class="st31" d="M25.2,174.8c-0.1-0.1-0.1-0.1-0.2-0.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -36,7 +36,7 @@ export function addHistoryMetadata(item: CompletionItem, history: HistoryItem[])
};
}
export default class LoggingLanguageProvider extends LanguageProvider {
export default class LokiLanguageProvider extends LanguageProvider {
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
@@ -0,0 +1,15 @@
import Datasource from './datasource';
import LokiStartPage from './components/LokiStartPage';
import LokiQueryField from './components/LokiQueryField';
export class LokiConfigCtrl {
static templateUrl = 'partials/config.html';
}
export {
Datasource,
LokiConfigCtrl as ConfigCtrl,
LokiQueryField as ExploreQueryField,
LokiStartPage as ExploreStartPage,
};
@@ -1,28 +1,28 @@
{
"type": "datasource",
"name": "Grafana Logging",
"id": "logging",
"name": "Loki",
"id": "loki",
"metrics": false,
"alerting": false,
"annotations": false,
"logs": true,
"explore": true,
"info": {
"description": "Grafana Logging Data Source for Grafana",
"description": "Loki Logging Data Source for Grafana",
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/grafana_icon.svg",
"large": "img/grafana_icon.svg"
"small": "img/loki_icon.svg",
"large": "img/loki_icon.svg"
},
"links": [
{
"name": "Grafana Logging",
"url": "https://grafana.com/"
"name": "Loki",
"url": "https://github.com/grafana/loki"
}
],
"version": "5.3.0"
}
}
}
@@ -18,6 +18,7 @@ const tokenizer = {
greedy: true,
alias: 'attr-value',
},
punctuation: /[{]/,
},
},
// number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
@@ -151,8 +151,7 @@ table_schema IN (
buildDatatypeQuery(column: string) {
let query = 'SELECT udt_name FROM information_schema.columns WHERE ';
query += this.buildSchemaConstraint();
query += ' AND table_name = ' + this.quoteIdentAsLiteral(this.target.table);
query += this.buildTableConstraint(this.target.table);
query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
return query;
}
@@ -21,7 +21,7 @@ const CHEAT_SHEET_ITEMS = [
export default (props: any) => (
<div>
<h1>PromQL Cheat Sheet</h1>
<h2>PromQL Cheat Sheet</h2>
{CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item__title">{item.title}</div>
@@ -1,59 +1,15 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import PromCheatSheet from './PromCheatSheet';
const TAB_MENU_ITEMS = [
{
text: 'Start',
id: 'start',
icon: 'fa fa-rocket',
},
];
export default class PromStart extends PureComponent<any, { active: string }> {
state = {
active: 'start',
};
onClickTab = active => {
this.setState({ active });
};
interface Props {
onClickExample: () => void;
}
export default class PromStart extends PureComponent<Props> {
render() {
const { active } = this.state;
const customCss = '';
return (
<div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
<nav>
<ul className={`gf-tabs ${customCss}`}>
{TAB_MENU_ITEMS.map((tab, idx) => {
const tabClasses = classNames({
'gf-tabs-link': true,
active: tab.id === active,
});
return (
<li className="gf-tabs-item" key={tab.id}>
<a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
})}
</ul>
</nav>
</div>
</div>
</div>
<div className="page-container page-body">
{active === 'start' && <PromCheatSheet onClickExample={this.props.onClickExample} />}
</div>
<div className="grafana-info-box grafana-info-box--max-lg">
<PromCheatSheet onClickExample={this.props.onClickExample} />
</div>
);
}
@@ -386,9 +386,10 @@ const tokenizer = {
lookbehind: true,
inside: {
'label-key': {
pattern: /[^,\s][^,]*[^,\s]*/,
pattern: /[^(),\s][^,)]*[^),\s]*/,
alias: 'attr-name',
},
punctuation: /[()]/,
},
},
'context-labels': {
@@ -403,6 +404,7 @@ const tokenizer = {
greedy: true,
alias: 'attr-value',
},
punctuation: /[{]/,
},
},
function: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})(?=\\s*\\()`, 'i'),
@@ -16,7 +16,7 @@ export class ResultTransformer {
options.valueWithRefId
),
];
} else if (options.format === 'heatmap') {
} else if (prometheusResult && options.format === 'heatmap') {
let seriesList = [];
prometheusResult.sort(sortSeriesByLabel);
for (const metricData of prometheusResult) {
@@ -24,7 +24,7 @@ export class ResultTransformer {
}
seriesList = this.transformToHistogramOverTime(seriesList);
return seriesList;
} else {
} else if (prometheusResult) {
const seriesList = [];
for (const metricData of prometheusResult) {
if (response.data.data.resultType === 'matrix') {
@@ -82,7 +82,7 @@ export class ResultTransformer {
let i, j;
const metricLabels = {};
if (md.length === 0) {
if (!md || md.length === 0) {
return table;
}
@@ -10,6 +10,31 @@ describe('Prometheus Result Transformer', () => {
ctx.resultTransformer = new ResultTransformer(ctx.templateSrv);
});
describe('When nothing is returned', () => {
test('should return empty series', () => {
const response = {
status: 'success',
data: {
resultType: '',
result: null,
},
};
const series = ctx.resultTransformer.transform({ data: response }, {});
expect(series).toEqual([]);
});
test('should return empty table', () => {
const response = {
status: 'success',
data: {
resultType: '',
result: null,
},
};
const table = ctx.resultTransformer.transform({ data: response }, { format: 'table' });
expect(table).toMatchObject([{ type: 'table', rows: [] }]);
});
});
describe('When resultFormat is table', () => {
const response = {
status: 'success',
@@ -130,6 +130,33 @@ describe('TimeRegionManager', () => {
});
});
plotOptionsScenario('for time from/to region', ctx => {
const regions = [{ from: '00:00', to: '05:00', fill: true, colorMode: 'red' }];
const from = moment('2018-12-01T00:00+01:00');
const to = moment('2018-12-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill between 00:00 and 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-01T01:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-12-02T01:00:00+01:00').format());
expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-12-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-12-03T01:00:00+01:00').format());
expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-12-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from/to region', ctx => {
const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = moment('2018-01-01T18:45:05+01:00');
@@ -211,6 +238,42 @@ describe('TimeRegionManager', () => {
});
});
plotOptionsScenario('for day of week from/to time region', ctx => {
const regions = [{ fromDayOfWeek: 7, from: '23:00', toDayOfWeek: 1, to: '01:40', fill: true, colorMode: 'red' }];
const from = moment('2018-12-07T12:51:19+01:00');
const to = moment('2018-12-10T13:51:29+01:00');
ctx.setup(regions, from, to);
it('should add 1 marking', () => {
expect(ctx.options.grid.markings.length).toBe(1);
});
it('should add one fill between sunday 23:00 and monday 01:40', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-10T00:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-10T02:40:00+01:00').format());
});
});
plotOptionsScenario('for day of week from/to time region', ctx => {
const regions = [{ fromDayOfWeek: 6, from: '03:00', toDayOfWeek: 7, to: '02:00', fill: true, colorMode: 'red' }];
const from = moment('2018-12-07T12:51:19+01:00');
const to = moment('2018-12-10T13:51:29+01:00');
ctx.setup(regions, from, to);
it('should add 1 marking', () => {
expect(ctx.options.grid.markings.length).toBe(1);
});
it('should add one fill between saturday 03:00 and sunday 02:00', () => {
const markings = ctx.options.grid.markings;
expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-08T04:00:00+01:00').format());
expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-09T03:00:00+01:00').format());
});
});
plotOptionsScenario('for day of week from/to time region with daylight saving time', ctx => {
const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }];
const from = moment('2018-03-17T06:00:00+01:00');
@@ -87,6 +87,14 @@ export class TimeRegionManager {
continue;
}
if (timeRegion.from && !timeRegion.to) {
timeRegion.to = timeRegion.from;
}
if (!timeRegion.from && timeRegion.to) {
timeRegion.from = timeRegion.to;
}
hRange = {
from: this.parseTimeRange(timeRegion.from),
to: this.parseTimeRange(timeRegion.to),
@@ -108,21 +116,13 @@ export class TimeRegionManager {
hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek);
}
if (!hRange.from.h && hRange.to.h) {
hRange.from = hRange.to;
}
if (hRange.from.h && !hRange.to.h) {
hRange.to = hRange.from;
}
if (hRange.from.dayOfWeek && !hRange.from.h && !hRange.from.m) {
if (hRange.from.dayOfWeek && hRange.from.h === null && hRange.from.m === null) {
hRange.from.h = 0;
hRange.from.m = 0;
hRange.from.s = 0;
}
if (hRange.to.dayOfWeek && !hRange.to.h && !hRange.to.m) {
if (hRange.to.dayOfWeek && hRange.to.h === null && hRange.to.m === null) {
hRange.to.h = 23;
hRange.to.m = 59;
hRange.to.s = 59;
@@ -169,8 +169,16 @@ export class TimeRegionManager {
fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
} else if (hRange.from.h + hRange.to.h < 23) {
fromEnd.add(hRange.to.h, 'hours');
while (fromEnd.hour() !== hRange.to.h) {
fromEnd.add(-1, 'hours');
}
} else {
fromEnd.add(24 - hRange.from.h, 'hours');
while (fromEnd.hour() !== hRange.to.h) {
fromEnd.add(1, 'hours');
}
}
fromEnd.set('minute', hRange.to.m);
@@ -107,7 +107,10 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
onDataReceived(dataList) {
const data: any = {};
const data: any = {
scopedVars: _.extend({}, this.panel.scopedVars),
};
if (dataList.length > 0 && dataList[0].type === 'table') {
this.dataType = 'table';
const tableData = dataList.map(this.tableHandler.bind(this));
@@ -117,6 +120,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.series = dataList.map(this.seriesHandler.bind(this));
this.setValues(data);
}
this.data = data;
this.render();
}
@@ -320,7 +324,6 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
// Add $__name variable for using in prefix or postfix
data.scopedVars = _.extend({}, this.panel.scopedVars);
data.scopedVars['__name'] = { value: this.series[0].label };
}
this.setValueMapping(data);
+1
View File
@@ -164,6 +164,7 @@ export interface ExploreState {
graphResult?: any[];
history: HistoryItem[];
initialQueries: DataQuery[];
logsHighlighterExpressions?: string[];
logsResult?: LogsModel;
queryTransactions: QueryTransaction[];
range: RawTimeRange;
+2
View File
@@ -19,6 +19,7 @@ import {
DataQuery,
DataQueryResponse,
DataQueryOptions,
IntervalValues,
} from './series';
import { PanelProps, PanelOptionsProps } from './panel';
import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
@@ -87,6 +88,7 @@ export {
AppNotificationTimeout,
DashboardSearchHit,
UserState,
IntervalValues,
};
export interface StoreState {
+18
View File
@@ -19,6 +19,11 @@ export interface TimeRange {
raw: RawTimeRange;
}
export interface IntervalValues {
interval: string; // 10s,5m
intervalMs: number;
}
export type TimeSeriesValue = string | number | null;
export type TimeSeriesPoints = TimeSeriesValue[][];
@@ -89,6 +94,11 @@ export interface DataQueryOptions {
}
export interface DataSourceApi {
/**
* min interval range
*/
interval?: string;
/**
* Imports queries from a different datasource
*/
@@ -97,6 +107,14 @@ export interface DataSourceApi {
* Initializes a datasource after instantiation
*/
init?: () => void;
/**
* Main data query method
*/
query(options: DataQueryOptions): Promise<DataQueryResponse>;
/**
* test data source
*/
testDatasource?: () => Promise<any>;
}
+2
View File
@@ -59,6 +59,7 @@
@import 'components/panel_text';
@import 'components/panel_heatmap';
@import 'components/panel_add_panel';
@import 'components/panel_logs';
@import 'components/settings_permissions';
@import 'components/tagsinput';
@import 'components/tables_lists';
@@ -101,6 +102,7 @@
@import 'components/delete_button';
@import 'components/add_data_source.scss';
@import 'components/page_loader';
@import 'components/toggle_button_group';
// PAGES
@import 'pages/login';
+8 -4
View File
@@ -44,9 +44,10 @@ $brand-success: $green;
$brand-warning: $brand-primary;
$brand-danger: $red;
$query-red: $red;
$query-green: $green;
$query-purple: $purple;
$query-red: #e24d42;
$query-green: #74e680;
$query-purple: #fe85fc;
$query-keyword: #66d9ef;
$query-orange: $orange;
// Status colors
@@ -203,7 +204,7 @@ $search-filter-box-bg: $gray-blue;
// Typeahead
$typeahead-shadow: 0 5px 10px 0 $black;
$typeahead-selected-bg: $dark-4;
$typeahead-selected-color: $blue;
$typeahead-selected-color: $yellow;
// Dropdowns
// -------------------------
@@ -349,3 +350,6 @@ $diff-json-icon: $gray-7;
//Submenu
$variable-option-bg: $blue-dark;
// logs
$logs-color-unkown: $gray-2;
+6 -2
View File
@@ -49,6 +49,7 @@ $query-red: $red;
$query-green: $green;
$query-purple: $purple;
$query-orange: $orange;
$query-keyword: $blue;
// Status colors
// -------------------------
@@ -219,8 +220,8 @@ $search-filter-box-bg: $gray-7;
// Typeahead
$typeahead-shadow: 0 5px 10px 0 $gray-5;
$typeahead-selected-bg: lighten($blue, 57%);
$typeahead-selected-color: $blue;
$typeahead-selected-bg: $gray-6;
$typeahead-selected-color: $yellow;
// Dropdowns
// -------------------------
@@ -358,3 +359,6 @@ $diff-json-icon: $gray-4;
//Submenu
$variable-option-bg: $blue-light;
// logs
$logs-color-unkown: $gray-5;
-1
View File
@@ -199,7 +199,6 @@ small,
mark,
.mark {
padding: 0.2em;
background: $alert-warning-bg;
}
+4
View File
@@ -32,6 +32,10 @@
a {
@extend .external-link;
}
&--max-lg {
max-width: map-get($grid-breakpoints, 'lg');
}
}
.grafana-info-box__close {
+293
View File
@@ -0,0 +1,293 @@
$column-horizontal-spacing: 10px;
.logs-panel-controls {
display: flex;
background-color: $page-bg;
padding: $panel-padding;
padding-top: 10px;
border-radius: $border-radius;
margin: 2*$panel-margin 0;
border: $panel-border;
justify-items: flex-start;
align-items: flex-start;
flex-wrap: wrap;
> * {
margin-right: 1em;
}
}
.logs-panel-nodata {
> * {
margin-left: 0.5em;
}
}
.logs-panel-meta {
flex: 1;
color: $text-color-weak;
}
.logs-panel-meta__item {
margin-right: 1em;
}
.logs-panel-meta__label {
margin-right: 0.5em;
font-size: 0.9em;
font-weight: 500;
}
.logs-panel-meta__value {
font-family: $font-family-monospace;
}
.logs-panel-meta-item__labels {
// compensate for the labels padding
position: relative;
top: 4px;
}
.logs-rows {
font-family: $font-family-monospace;
font-size: 12px;
display: table;
table-layout: fixed;
}
.logs-row {
display: table-row;
> div {
display: table-cell;
padding-right: $column-horizontal-spacing;
vertical-align: middle;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
}
&:hover {
background: $page-bg;
}
}
.logs-row__time {
white-space: nowrap;
}
.logs-row__labels {
max-width: 20%;
line-height: 1.2;
}
.logs-row__message {
word-break: break-all;
min-width: 80%;
}
.logs-row__match-highlight {
// Undoing mark styling
background: inherit;
padding: inherit;
color: $typeahead-selected-color;
border-bottom: 1px solid $typeahead-selected-color;
background-color: rgba($typeahead-selected-color, 0.1);
&--preview {
background-color: rgba($typeahead-selected-color, 0.2);
border-bottom-style: dotted;
}
}
.logs-row__level {
position: relative;
&::after {
content: '';
display: block;
position: absolute;
top: 1px;
bottom: 1px;
width: 3px;
background-color: $logs-color-unkown;
}
&--critical,
&--crit {
&::after {
background-color: #705da0;
}
}
&--error,
&--err {
&::after {
background-color: #e24d42;
}
}
&--warning,
&--warn {
&::after {
background-color: $yellow;
}
}
&--info {
&::after {
background-color: #7eb26d;
}
}
&--debug {
&::after {
background-color: #1f78c1;
}
}
&--trace {
&::after {
background-color: #6ed0e0;
}
}
}
.logs-row__duplicates {
text-align: right;
}
.logs-row__field-highlight {
// Undoing mark styling
background: inherit;
padding: inherit;
border-bottom: 1px dotted $typeahead-selected-color;
.logs-row__field-highlight--icon {
margin-left: 0.5em;
cursor: pointer;
display: none;
}
}
.logs-row__stats {
margin: 5px 0;
}
.logs-row__field-highlight:hover {
color: $typeahead-selected-color;
border-bottom-style: solid;
.logs-row__field-highlight--icon {
display: inline;
}
}
.logs-label {
display: inline-block;
padding: 0 2px;
background-color: $btn-inverse-bg;
border-radius: $border-radius;
margin: 0 4px 2px 0;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
}
.logs-label__icon {
border-left: $panel-border;
padding: 0 2px;
cursor: pointer;
margin-left: 2px;
}
.logs-label__stats {
position: absolute;
top: 1.25em;
left: -10px;
z-index: 100;
justify-content: space-between;
box-shadow: $popover-shadow;
}
/*
* Stats popover & message stats box
*/
.logs-stats {
background-color: $popover-bg;
color: $popover-color;
border: 1px solid $popover-border-color;
border-radius: $border-radius;
max-width: 500px;
}
.logs-stats__header {
background-color: $popover-border-color;
padding: 6px 10px;
display: flex;
}
.logs-stats__title {
font-weight: $font-weight-semi-bold;
padding-right: $spacer;
overflow: hidden;
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
}
.logs-stats__body {
padding: 20px 10px 10px 10px;
}
.logs-stats__close {
cursor: pointer;
}
.logs-stats-row {
margin: $spacer/1.75 0;
&--active {
color: $blue;
position: relative;
}
&--active::after {
display: inline;
content: '*';
position: absolute;
top: 0;
left: -8px;
}
&__label {
display: flex;
margin-bottom: 1px;
}
&__value {
flex: 1;
}
&__count,
&__percent {
text-align: right;
margin-left: 0.5em;
}
&__percent {
width: 3em;
}
&__bar,
&__innerbar {
height: 4px;
overflow: hidden;
background: $text-color-faint;
}
&__innerbar {
background: $blue;
}
}
+14 -14
View File
@@ -12,8 +12,8 @@
width: 100%;
cursor: text;
line-height: $line-height-base;
color: $text-color-weak;
background-color: $panel-bg;
color: $text-color;
background-color: $input-bg;
background-image: none;
border: $panel-border;
border-radius: $border-radius;
@@ -95,45 +95,45 @@
color: $text-color-weak;
}
.token.punctuation {
color: $text-color-weak;
.token.variable,
.token.entity {
color: $text-color;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.function-name,
.token.constant,
.token.symbol,
.token.deleted {
color: $query-red;
}
.token.attr-value,
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.function,
.token.builtin,
.token.inserted {
color: $query-green;
}
.token.boolean,
.token.number,
.token.operator,
.token.entity,
.token.url,
.token.variable {
.token.url {
color: $query-purple;
}
.token.function,
.token.attr-name,
.token.function-name,
.token.atrule,
.token.attr-value,
.token.keyword,
.token.class-name {
color: $blue;
color: $query-keyword;
}
.token.punctuation,
.token.regex,
.token.important {
color: $query-orange;

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