Merge branch 'master' into org-page-to-react

This commit is contained in:
Peter Holmberg
2018-10-29 14:26:11 +01:00
77 changed files with 1906 additions and 1010 deletions

View File

@@ -206,6 +206,10 @@ jobs:
- run: docker info - run: docker info
- run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
- run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}" - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}"
- run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cd packaging/docker && ./build-enterprise.sh "master"
grafana-docker-pr: grafana-docker-pr:
docker: docker:
@@ -230,6 +234,9 @@ jobs:
- run: docker info - run: docker info
- run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
- run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}" - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
- run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
build-enterprise: build-enterprise:
docker: docker:
@@ -409,6 +416,7 @@ workflows:
- grafana-docker-master: - grafana-docker-master:
requires: requires:
- build-all - build-all
- build-all-enterprise
- test-backend - test-backend
- test-frontend - test-frontend
- codespell - codespell

View File

@@ -13,11 +13,16 @@
* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
### Breaking changes ### Breaking changes
* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
# 5.3.3 (unreleased)
* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769)
# 5.3.2 (2018-10-24) # 5.3.2 (2018-10-24)
* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm) * **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm)

View File

@@ -9,12 +9,17 @@ module.exports = function (grunt) {
destDir: 'dist', destDir: 'dist',
tempDir: 'tmp', tempDir: 'tmp',
platform: process.platform.replace('win32', 'windows'), platform: process.platform.replace('win32', 'windows'),
enterprise: false,
}; };
if (grunt.option('platform')) { if (grunt.option('platform')) {
config.platform = grunt.option('platform'); config.platform = grunt.option('platform');
} }
if (grunt.option('enterprise')) {
config.enterprise = true;
}
if (grunt.option('arch')) { if (grunt.option('arch')) {
config.arch = grunt.option('arch'); config.arch = grunt.option('arch');
} else { } else {

View File

@@ -5,8 +5,7 @@ all: deps build
deps-go: deps-go:
go run build.go setup go run build.go setup
deps-js: deps-js: node_modules
yarn install --pure-lockfile --no-progress
deps: deps-js deps: deps-js
@@ -43,3 +42,10 @@ test: test-go test-js
run: run:
./bin/grafana-server ./bin/grafana-server
clean:
rm -rf node_modules
rm -rf public/build
node_modules: package.json yarn.lock
yarn install --pure-lockfile --no-progress

View File

@@ -403,6 +403,10 @@ func gruntBuildArg(task string) []string {
if phjsToRelease != "" { if phjsToRelease != "" {
args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease))
} }
if enterprise {
args = append(args, "--enterprise")
}
args = append(args, fmt.Sprintf("--platform=%v", goos)) args = append(args, fmt.Sprintf("--platform=%v", goos))
return args return args
@@ -467,6 +471,7 @@ func ldflags() string {
b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) b.WriteString(fmt.Sprintf(" -X main.version=%s", version))
b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha()))
b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp()))
b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch()))
return b.String() return b.String()
} }
@@ -514,6 +519,14 @@ func setBuildEnv() {
} }
} }
func getGitBranch() string {
v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "master"
}
return string(v)
}
func getGitSha() string { func getGitSha() string {
v, err := runError("git", "rev-parse", "--short", "HEAD") v, err := runError("git", "rev-parse", "--short", "HEAD")
if err != nil { if err != nil {

View File

@@ -927,6 +927,123 @@
"title": "", "title": "",
"type": "text" "type": "text"
}, },
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 0,
"gridPos": {
"h": 7,
"w": 16,
"x": 0,
"y": 44
},
"id": 21,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [
{
"alias": "C-series",
"steppedLine": true
}
],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"alias": "",
"hide": false,
"refId": "B",
"scenarioId": "csv_metric_values",
"stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null",
"target": ""
},
{
"alias": "",
"hide": false,
"refId": "C",
"scenarioId": "csv_metric_values",
"stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Null between points",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 8,
"x": 16,
"y": 44
},
"id": 22,
"links": [],
"mode": "markdown",
"title": "",
"type": "text"
},
{ {
"aliasColors": {}, "aliasColors": {},
"bars": false, "bars": false,
@@ -939,7 +1056,7 @@
"h": 7, "h": 7,
"w": 24, "w": 24,
"x": 0, "x": 0,
"y": 44 "y": 51
}, },
"id": 20, "id": 20,
"legend": { "legend": {
@@ -1024,7 +1141,7 @@
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 0, "x": 0,
"y": 51 "y": 58
}, },
"id": 16, "id": 16,
"legend": { "legend": {
@@ -1127,7 +1244,7 @@
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 51 "y": 58
}, },
"id": 17, "id": 17,
"legend": { "legend": {
@@ -1266,7 +1383,7 @@
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 0, "x": 0,
"y": 58 "y": 65
}, },
"id": 18, "id": 18,
"legend": { "legend": {
@@ -1370,7 +1487,7 @@
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 58 "y": 65
}, },
"id": 19, "id": 19,
"legend": { "legend": {
@@ -1554,5 +1671,5 @@
"timezone": "browser", "timezone": "browser",
"title": "Panel Tests - Graph", "title": "Panel Tests - Graph",
"uid": "5SdHCadmz", "uid": "5SdHCadmz",
"version": 3 "version": 1
} }

View File

@@ -158,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source | | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
| timeField | string | Elasticsearch | Which field that should be used as timestamp | | timeField | string | Elasticsearch | Which field that should be used as timestamp |
| interval | string | Elasticsearch | Index date time format | | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
| defaultRegion | string | Cloudwatch | AWS region | | defaultRegion | string | Cloudwatch | AWS region |

View File

@@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special
characters. Make sure you always use the group or subgroup name as it appears characters. Make sure you always use the group or subgroup name as it appears
in the URL of the group or subgroup. in the URL of the group or subgroup.
Here's a complete example with `alloed_sign_up` enabled, and access limited to Here's a complete example with `allow_sign_up` enabled, and access limited to
the `example` and `foo/bar` groups: the `example` and `foo/bar` groups:
```ini ```ini
[auth.gitlab] [auth.gitlab]
enabled = false enabled = true
allow_sign_up = true allow_sign_up = true
client_id = GITLAB_APPLICATION_ID client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET client_secret = GITLAB_SECRET

View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
_grafana_tag=$1
_docker_repo=${2:-grafana/grafana-enterprise}
docker build \
--tag "${_docker_repo}:${_grafana_tag}"\
--no-cache=true \
.

View File

@@ -1,29 +0,0 @@
# New Grafana Release Processes
## Building release packages
1) Update package.json so that it has the right version.
2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"`
3) Push branch & tag to github!
2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
### Non master branch
When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25,
you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7.
### Windows build
Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min)
and fail quite often for random reasons so I usually continue with the release process without a windows build already built.
1) Click on the green build that has the correct version and tag
2) Click on `DEPLOYMENTS`
3) Click on `NEW DEPLOYMENT`
4) Select GrafanaBuildS3
4) Select the build you want to deploy.
The deployment should be quick (just uploads the release zip file to S3)

View File

@@ -3,6 +3,8 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"net/http"
_ "net/http/pprof"
"os" "os"
"os/signal" "os/signal"
"runtime" "runtime"
@@ -11,16 +13,12 @@ import (
"syscall" "syscall"
"time" "time"
"net/http" extensions "github.com/grafana/grafana/pkg/extensions"
_ "net/http/pprof"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/setting"
extensions "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions"
_ "github.com/grafana/grafana/pkg/services/alerting/notifiers" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
"github.com/grafana/grafana/pkg/setting"
_ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch"
_ "github.com/grafana/grafana/pkg/tsdb/elasticsearch" _ "github.com/grafana/grafana/pkg/tsdb/elasticsearch"
_ "github.com/grafana/grafana/pkg/tsdb/graphite" _ "github.com/grafana/grafana/pkg/tsdb/graphite"
@@ -35,6 +33,7 @@ import (
var version = "5.0.0" var version = "5.0.0"
var commit = "NA" var commit = "NA"
var buildBranch = "master"
var buildstamp string var buildstamp string
var configFile = flag.String("config", "", "path to config file") var configFile = flag.String("config", "", "path to config file")
@@ -47,7 +46,7 @@ func main() {
profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling") profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling")
flag.Parse() flag.Parse()
if *v { if *v {
fmt.Printf("Version %s (commit: %s)\n", version, commit) fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch)
os.Exit(0) os.Exit(0)
} }
@@ -78,6 +77,7 @@ func main() {
setting.BuildVersion = version setting.BuildVersion = version
setting.BuildCommit = commit setting.BuildCommit = commit
setting.BuildStamp = buildstampInt64 setting.BuildStamp = buildstampInt64
setting.BuildBranch = buildBranch
setting.IsEnterprise = extensions.IsEnterprise setting.IsEnterprise = extensions.IsEnterprise
metrics.M_Grafana_Version.WithLabelValues(version).Set(1) metrics.M_Grafana_Version.WithLabelValues(version).Set(1)

View File

@@ -12,24 +12,16 @@ import (
"time" "time"
"github.com/facebookgo/inject" "github.com/facebookgo/inject"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" _ "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/registry"
"golang.org/x/sync/errgroup"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social"
// self registering services
_ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/metrics" _ "github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/middleware"
_ "github.com/grafana/grafana/pkg/plugins" _ "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
_ "github.com/grafana/grafana/pkg/services/alerting" _ "github.com/grafana/grafana/pkg/services/alerting"
_ "github.com/grafana/grafana/pkg/services/cleanup" _ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications" _ "github.com/grafana/grafana/pkg/services/notifications"
@@ -37,7 +29,10 @@ import (
_ "github.com/grafana/grafana/pkg/services/rendering" _ "github.com/grafana/grafana/pkg/services/rendering"
_ "github.com/grafana/grafana/pkg/services/search" _ "github.com/grafana/grafana/pkg/services/search"
_ "github.com/grafana/grafana/pkg/services/sqlstore" _ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social" // self registering services
_ "github.com/grafana/grafana/pkg/tracing" _ "github.com/grafana/grafana/pkg/tracing"
"golang.org/x/sync/errgroup"
) )
func NewGrafanaServer() *GrafanaServerImpl { func NewGrafanaServer() *GrafanaServerImpl {
@@ -159,7 +154,7 @@ func (g *GrafanaServerImpl) loadConfiguration() {
os.Exit(1) os.Exit(1)
} }
g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0))
g.cfg.LogConfigSources() g.cfg.LogConfigSources()
} }

View File

@@ -185,7 +185,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) { if ldapUser.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole extUser.OrgRoles[group.OrgId] = group.OrgRole
if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false { if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin {
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
} }
} }

View File

@@ -43,12 +43,13 @@ func GetContextHandler() macaron.Handler {
// then init session and look for userId in session // then init session and look for userId in session
// then look for api key in session (special case for render calls via api) // then look for api key in session (special case for render calls via api)
// then test if anonymous access is enabled // then test if anonymous access is enabled
if initContextWithRenderAuth(ctx) || switch {
initContextWithApiKey(ctx) || case initContextWithRenderAuth(ctx):
initContextWithBasicAuth(ctx, orgId) || case initContextWithApiKey(ctx):
initContextWithAuthProxy(ctx, orgId) || case initContextWithBasicAuth(ctx, orgId):
initContextWithUserSessionCookie(ctx, orgId) || case initContextWithAuthProxy(ctx, orgId):
initContextWithAnonymousUser(ctx) { case initContextWithUserSessionCookie(ctx, orgId):
case initContextWithAnonymousUser(ctx):
} }
ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login) ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login)

View File

@@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error {
pm.checkForUpdates() pm.checkForUpdates()
case <-ctx.Done(): case <-ctx.Done():
run = false run = false
break
} }
} }

View File

@@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader {
func (arr *DefaultRuleReader) initReader() { func (arr *DefaultRuleReader) initReader() {
heartbeat := time.NewTicker(time.Second * 10) heartbeat := time.NewTicker(time.Second * 10)
for { for range heartbeat.C {
select { arr.heartbeat()
case <-heartbeat.C:
arr.heartbeat()
}
} }
} }

View File

@@ -13,15 +13,12 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"strings" "strings"
"gopkg.in/ini.v1"
"github.com/go-macaron/session"
"time" "time"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"gopkg.in/ini.v1"
) )
type Scheme string type Scheme string
@@ -49,6 +46,7 @@ var (
// build // build
BuildVersion string BuildVersion string
BuildCommit string BuildCommit string
BuildBranch string
BuildStamp int64 BuildStamp int64
IsEnterprise bool IsEnterprise bool
ApplicationName string ApplicationName string

View File

@@ -35,6 +35,7 @@ type CustomMetricsCache struct {
var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache
var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache
var regionCache sync.Map
func init() { func init() {
metricsMap = map[string][]string{ metricsMap = map[string][]string{
@@ -233,13 +234,20 @@ func parseMultiSelectValue(input string) []string {
// Whenever this list is updated, frontend list should also be updated. // Whenever this list is updated, frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
dsInfo := e.getDsInfo("default")
profile := dsInfo.Profile
if cache, ok := regionCache.Load(profile); ok {
if cache2, ok2 := cache.([]suggestData); ok2 {
return cache2, nil
}
}
regions := []string{ regions := []string{
"ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1",
"eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2",
"cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1", "cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1",
} }
err := e.ensureClientSession("default")
err := e.ensureClientSession("us-east-1")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -269,6 +277,7 @@ func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *s
for _, region := range regions { for _, region := range regions {
result = append(result, suggestData{Text: region, Value: region}) result = append(result, suggestData{Text: region, Value: region})
} }
regionCache.Store(profile, result)
return result, nil return result, nil
} }

View File

@@ -9,20 +9,26 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/bmizerany/assert" "github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/components/securejsondata"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
type mockedEc2 struct { type mockedEc2 struct {
ec2iface.EC2API ec2iface.EC2API
Resp ec2.DescribeInstancesOutput Resp ec2.DescribeInstancesOutput
RespRegions ec2.DescribeRegionsOutput
} }
func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
fn(&m.Resp, true) fn(&m.Resp, true)
return nil return nil
} }
func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) {
return &m.RespRegions, nil
}
func TestCloudWatchMetrics(t *testing.T) { func TestCloudWatchMetrics(t *testing.T) {
@@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) {
}) })
}) })
Convey("When calling handleGetRegions", t, func() {
executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{
Regions: []*ec2.Region{
{
RegionName: aws.String("ap-northeast-2"),
},
},
}},
}
jsonData := simplejson.New()
jsonData.Set("defaultRegion", "default")
executor.DataSource = &models.DataSource{
JsonData: jsonData,
SecureJsonData: securejsondata.SecureJsonData{},
}
result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{})
Convey("Should return regions", func() {
So(result[0].Text, ShouldEqual, "ap-northeast-1")
So(result[1].Text, ShouldEqual, "ap-northeast-2")
})
})
Convey("When calling handleGetEc2InstanceAttribute", t, func() { Convey("When calling handleGetEc2InstanceAttribute", t, func() {
executor := &CloudWatchExecutor{ executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{

View File

@@ -164,14 +164,12 @@ func formatTimeRange(input string) string {
func fixIntervalFormat(target string) string { func fixIntervalFormat(target string) string {
rMinute := regexp.MustCompile(`'(\d+)m'`) rMinute := regexp.MustCompile(`'(\d+)m'`)
rMin := regexp.MustCompile("m")
target = rMinute.ReplaceAllStringFunc(target, func(m string) string { target = rMinute.ReplaceAllStringFunc(target, func(m string) string {
return rMin.ReplaceAllString(m, "min") return strings.Replace(m, "m", "min", -1)
}) })
rMonth := regexp.MustCompile(`'(\d+)M'`) rMonth := regexp.MustCompile(`'(\d+)M'`)
rMon := regexp.MustCompile("M")
target = rMonth.ReplaceAllStringFunc(target, func(M string) string { target = rMonth.ReplaceAllStringFunc(target, func(M string) string {
return rMon.ReplaceAllString(M, "mon") return strings.Replace(M, "M", "mon", -1)
}) })
return target return target
} }

View File

@@ -60,7 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name) return "", fmt.Errorf("missing time column argument for macro %v", name)
} }
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil
case "__timeGroup": case "__timeGroup":
if len(args) < 2 { if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name) return "", fmt.Errorf("macro %v needs time column and interval", name)

View File

@@ -60,7 +60,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
@@ -92,7 +92,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {
@@ -112,7 +112,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
}) })
Convey("interpolate __unixEpochFilter function", func() { Convey("interpolate __unixEpochFilter function", func() {

View File

@@ -186,8 +186,7 @@ func reverse(s string) string {
} }
func interpolateFilterWildcards(value string) string { func interpolateFilterWildcards(value string) string {
re := regexp.MustCompile("[*]") matches := strings.Count(value, "*")
matches := len(re.FindAllStringIndex(value, -1))
if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") { if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") {
value = strings.Replace(value, "*", "", -1) value = strings.Replace(value, "*", "", -1)
value = fmt.Sprintf(`has_substring("%s")`, value) value = fmt.Sprintf(`has_substring("%s")`, value)

View File

@@ -0,0 +1,28 @@
import { AppNotification } from 'app/types/';
export enum ActionTypes {
AddAppNotification = 'ADD_APP_NOTIFICATION',
ClearAppNotification = 'CLEAR_APP_NOTIFICATION',
}
interface AddAppNotificationAction {
type: ActionTypes.AddAppNotification;
payload: AppNotification;
}
interface ClearAppNotificationAction {
type: ActionTypes.ClearAppNotification;
payload: number;
}
export type Action = AddAppNotificationAction | ClearAppNotificationAction;
export const clearAppNotification = (appNotificationId: number) => ({
type: ActionTypes.ClearAppNotification,
payload: appNotificationId,
});
export const notifyApp = (appNotification: AppNotification) => ({
type: ActionTypes.AddAppNotification,
payload: appNotification,
});

View File

@@ -1,4 +1,5 @@
import { updateLocation } from './location'; import { updateLocation } from './location';
import { updateNavIndex, UpdateNavIndexAction } from './navModel'; import { updateNavIndex, UpdateNavIndexAction } from './navModel';
import { notifyApp, clearAppNotification } from './appNotification';
export { updateLocation, updateNavIndex, UpdateNavIndexAction }; export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification };

View File

@@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult'; import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter'; import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu'; import { SideMenu } from './components/sidemenu/SideMenu';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
export function registerAngularDirectives() { export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']); react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []); react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []); react2AngularDirective('searchResult', SearchResult, []);

View File

@@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
interface Props {
appNotification: AppNotification;
onClearNotification: (id) => void;
}
export default class AppNotificationItem extends Component<Props> {
shouldComponentUpdate(nextProps) {
return this.props.appNotification.id !== nextProps.appNotification.id;
}
componentDidMount() {
const { appNotification, onClearNotification } = this.props;
setTimeout(() => {
onClearNotification(appNotification.id);
}, appNotification.timeout);
}
render() {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">
<i className={appNotification.icon} />
</div>
<div className="alert-body">
<div className="alert-title">{appNotification.title}</div>
<div className="alert-text">{appNotification.text}</div>
</div>
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
<i className="fa fa fa-remove" />
</button>
</div>
);
}
}

View File

@@ -0,0 +1,60 @@
import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { AppNotification, StoreState } from 'app/types';
import {
createErrorNotification,
createSuccessNotification,
createWarningNotification,
} from '../../copy/appNotification';
export interface Props {
appNotifications: AppNotification[];
notifyApp: typeof notifyApp;
clearAppNotification: typeof clearAppNotification;
}
export class AppNotificationList extends PureComponent<Props> {
componentDidMount() {
const { notifyApp } = this.props;
appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1])));
appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1])));
appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1])));
}
onClearAppNotification = id => {
this.props.clearAppNotification(id);
};
render() {
const { appNotifications } = this.props;
return (
<div>
{appNotifications.map((appNotification, index) => {
return (
<AppNotificationItem
key={`${appNotification.id}-${index}`}
appNotification={appNotification}
onClearNotification={id => this.onClearAppNotification(id)}
/>
);
})}
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
appNotifications: state.appNotifications.appNotifications,
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
};
export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);

View File

@@ -19,3 +19,4 @@ export const Label: SFC<Props> = props => {
</span> </span>
); );
}; };

View File

@@ -0,0 +1,46 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
export interface Props {
label: string;
checked: boolean;
labelClass?: string;
switchClass?: string;
onChange: (event) => any;
}
export interface State {
id: any;
}
export class Switch extends PureComponent<Props, State> {
state = {
id: _.uniqueId(),
};
internalOnChange = event => {
event.stopPropagation();
this.props.onChange(event);
};
render() {
const { labelClass, switchClass, label, checked } = this.props;
const labelId = `check-${this.state.id}`;
const labelClassName = `gf-form-label ${labelClass} pointer`;
const switchClassName = `gf-form-switch ${switchClass}`;
return (
<div className="gf-form">
{label && (
<label htmlFor={labelId} className={labelClassName}>
{label}
</label>
)}
<div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
<label htmlFor={labelId} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,46 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
const defaultSuccessNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Success,
icon: 'fa fa-check',
timeout: AppNotificationTimeout.Success,
};
const defaultWarningNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Warning,
icon: 'fa fa-exclamation',
timeout: AppNotificationTimeout.Warning,
};
const defaultErrorNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Error,
icon: 'fa fa-exclamation-triangle',
timeout: AppNotificationTimeout.Error,
};
export const createSuccessNotification = (title: string, text?: string): AppNotification => ({
...defaultSuccessNotification,
title: title,
text: text,
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
...defaultErrorNotification,
title: title,
text: text,
id: Date.now(),
});
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,
title: title,
text: text,
id: Date.now(),
});

View File

@@ -0,0 +1,51 @@
import { appNotificationsReducer } from './appNotification';
import { ActionTypes } from '../actions/appNotification';
import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/';
describe('clear alert', () => {
it('should filter alert', () => {
const id1 = 1540301236048;
const id2 = 1540301248293;
const initialState = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
{
id: id2,
severity: AppNotificationSeverity.Warning,
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
},
],
};
const result = appNotificationsReducer(initialState, {
type: ActionTypes.ClearAppNotification,
payload: id2,
});
const expectedResult = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
],
};
expect(result).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,19 @@
import { AppNotification, AppNotificationsState } from 'app/types/';
import { Action, ActionTypes } from '../actions/appNotification';
export const initialState: AppNotificationsState = {
appNotifications: [] as AppNotification[],
};
export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => {
switch (action.type) {
case ActionTypes.AddAppNotification:
return { ...state, appNotifications: state.appNotifications.concat([action.payload]) };
case ActionTypes.ClearAppNotification:
return {
...state,
appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload),
};
}
return state;
};

View File

@@ -1,7 +1,9 @@
import { navIndexReducer as navIndex } from './navModel'; import { navIndexReducer as navIndex } from './navModel';
import { locationReducer as location } from './location'; import { locationReducer as location } from './location';
import { appNotificationsReducer as appNotifications } from './appNotification';
export default { export default {
navIndex, navIndex,
location, location,
appNotifications,
}; };

View File

@@ -1,100 +1,12 @@
import angular from 'angular';
import _ from 'lodash';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export class AlertSrv { export class AlertSrv {
list: any[]; constructor() {}
/** @ngInject */ set() {
constructor(private $timeout, private $rootScope) { console.log('old depricated alert srv being used');
this.list = [];
}
init() {
this.$rootScope.onAppEvent(
'alert-error',
(e, alert) => {
this.set(alert[0], alert[1], 'error', 12000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-warning',
(e, alert) => {
this.set(alert[0], alert[1], 'warning', 5000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-success',
(e, alert) => {
this.set(alert[0], alert[1], 'success', 3000);
},
this.$rootScope
);
appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000));
appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000));
appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000));
}
getIconForSeverity(severity) {
switch (severity) {
case 'success':
return 'fa fa-check';
case 'error':
return 'fa fa-exclamation-triangle';
default:
return 'fa fa-exclamation';
}
}
set(title, text, severity, timeout) {
if (_.isObject(text)) {
console.log('alert error', text);
if (text.statusText) {
text = `HTTP Error (${text.status}) ${text.statusText}`;
}
}
const newAlert = {
title: title || '',
text: text || '',
severity: severity || 'info',
icon: this.getIconForSeverity(severity),
};
const newAlertJson = angular.toJson(newAlert);
// remove same alert if it already exists
_.remove(this.list, value => {
return angular.toJson(value) === newAlertJson;
});
this.list.push(newAlert);
if (timeout > 0) {
this.$timeout(() => {
this.list = _.without(this.list, newAlert);
}, timeout);
}
if (!this.$rootScope.$$phase) {
this.$rootScope.$digest();
}
return newAlert;
}
clear(alert) {
this.list = _.without(this.list, alert);
}
clearAll() {
this.list = [];
} }
} }
// this is just added to not break old plugins that might be using it
coreModule.service('alertSrv', AlertSrv); coreModule.service('alertSrv', AlertSrv);

View File

@@ -9,7 +9,7 @@ export class BackendSrv {
private noBackendCache: boolean; private noBackendCache: boolean;
/** @ngInject */ /** @ngInject */
constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {} constructor(private $http, private $q, private $timeout, private contextSrv) {}
get(url, params?) { get(url, params?) {
return this.request({ method: 'GET', url: url, params: params }); return this.request({ method: 'GET', url: url, params: params });
@@ -49,14 +49,14 @@ export class BackendSrv {
} }
if (err.status === 422) { if (err.status === 422) {
this.alertSrv.set('Validation failed', data.message, 'warning', 4000); appEvents.emit('alert-warning', ['Validation failed', data.message]);
throw data; throw data;
} }
data.severity = 'error'; let severity = 'error';
if (err.status < 500) { if (err.status < 500) {
data.severity = 'warning'; severity = 'warning';
} }
if (data.message) { if (data.message) {
@@ -66,7 +66,8 @@ export class BackendSrv {
description = message; description = message;
message = 'Error'; message = 'Error';
} }
this.alertSrv.set(message, description, data.severity, 10000);
appEvents.emit('alert-' + severity, [message, description]);
} }
throw data; throw data;
@@ -93,7 +94,7 @@ export class BackendSrv {
if (options.method !== 'GET') { if (options.method !== 'GET') {
if (results && results.data.message) { if (results && results.data.message) {
if (options.showSuccessAlert !== false) { if (options.showSuccessAlert !== false) {
this.alertSrv.set(results.data.message, '', 'success', 3000); appEvents.emit('alert-success', [results.data.message]);
} }
} }
} }

View File

@@ -9,7 +9,7 @@ describe('backend_srv', () => {
return Promise.resolve({}); return Promise.resolve({});
}; };
const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {}); const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {});
describe('when handling errors', () => { describe('when handling errors', () => {
it('should return the http status code', async () => { it('should return the http status code', async () => {

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { store } from '../../store/configureStore';
export function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}

View File

@@ -1,25 +1,32 @@
import './editor_ctrl'; // Libaries
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
// Components
import './editor_ctrl';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
// Utils & Services
import { makeRegions, dedupAnnotations } from './events_processing'; import { makeRegions, dedupAnnotations } from './events_processing';
// Types
import { DashboardModel } from '../dashboard/dashboard_model';
export class AnnotationsSrv { export class AnnotationsSrv {
globalAnnotationsPromise: any; globalAnnotationsPromise: any;
alertStatesPromise: any; alertStatesPromise: any;
datasourcePromises: any; datasourcePromises: any;
/** @ngInject */ /** @ngInject */
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) { constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
$rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
}
clearCache() { init(dashboard: DashboardModel) {
this.globalAnnotationsPromise = null; // clear promises on refresh events
this.alertStatesPromise = null; dashboard.on('refresh', () => {
this.datasourcePromises = null; this.globalAnnotationsPromise = null;
this.alertStatesPromise = null;
this.datasourcePromises = null;
});
} }
getAnnotations(options) { getAnnotations(options) {

View File

@@ -1,6 +1,12 @@
// Utils
import config from 'app/core/config'; import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
// Services
import { AnnotationsSrv } from '../annotations/annotations_srv';
// Types
import { DashboardModel } from './dashboard_model'; import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model'; import { PanelModel } from './panel_model';
@@ -21,6 +27,7 @@ export class DashboardCtrl {
private dashboardSrv, private dashboardSrv,
private unsavedChangesSrv, private unsavedChangesSrv,
private dashboardViewStateSrv, private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv public playlistSrv
) { ) {
// temp hack due to way dashboards are loaded // temp hack due to way dashboards are loaded
@@ -49,6 +56,7 @@ export class DashboardCtrl {
// init services // init services
this.timeSrv.init(dashboard); this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts); this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before // template values service needs to initialize completely before
// the rest of the dashboard can load // the rest of the dashboard can load
@@ -72,7 +80,7 @@ export class DashboardCtrl {
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme(); this.setWindowTitleAndTheme();
this.$scope.appEvent('dashboard-initialized', dashboard); appEvents.emit('dashboard-initialized', dashboard);
}) })
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
} }

View File

@@ -21,15 +21,14 @@ function GridWrapper({
className, className,
isResizable, isResizable,
isDraggable, isDraggable,
isFullscreen,
}) { }) {
if (size.width === 0) {
console.log('size is zero!');
}
const width = size.width > 0 ? size.width : lastGridWidth; const width = size.width > 0 ? size.width : lastGridWidth;
if (width !== lastGridWidth) { if (width !== lastGridWidth) {
onWidthChange(); if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
lastGridWidth = width; onWidthChange();
lastGridWidth = width;
}
} }
return ( return (
@@ -197,6 +196,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
onDragStop={this.onDragStop} onDragStop={this.onDragStop}
onResize={this.onResize} onResize={this.onResize}
onResizeStop={this.onResizeStop} onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
> >
{this.renderPanels()} {this.renderPanels()}
</SizedReactLayoutGrid> </SizedReactLayoutGrid>

View File

@@ -1,5 +1,4 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import Tooltip from 'app/core/components/Tooltip/Tooltip'; import Tooltip from 'app/core/components/Tooltip/Tooltip';
import SlideDown from 'app/core/components/Animations/SlideDown'; import SlideDown from 'app/core/components/Animations/SlideDown';
import { StoreState, FolderInfo } from 'app/types'; import { StoreState, FolderInfo } from 'app/types';
@@ -13,7 +12,7 @@ import {
import PermissionList from 'app/core/components/PermissionList/PermissionList'; import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission'; import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
import { store } from 'app/store/configureStore'; import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
export interface Props { export interface Props {
dashboardId: number; dashboardId: number;
@@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent<Props, State> {
} }
} }
function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}
const mapStateToProps = (state: StoreState) => ({ const mapStateToProps = (state: StoreState) => ({
permissions: state.dashboard.permissions, permissions: state.dashboard.permissions,
}); });

View File

@@ -11,7 +11,7 @@ const template = `
`; `;
/** @ngInject */ /** @ngInject */
function uploadDashboardDirective(timer, alertSrv, $location) { function uploadDashboardDirective(timer, $location) {
return { return {
restrict: 'E', restrict: 'E',
template: template, template: template,
@@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
// Something // Something
elem[0].addEventListener('change', file_selected, false); elem[0].addEventListener('change', file_selected, false);
} else { } else {
alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error'); appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']);
} }
}, },
}; };

View File

@@ -373,9 +373,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
}; };
onModifyQueries = (action: object, index?: number) => { onModifyQueries = (action, index?: number) => {
const { datasource } = this.state; const { datasource } = this.state;
if (datasource && datasource.modifyQuery) { if (datasource && datasource.modifyQuery) {
const preventSubmit = action.preventSubmit;
this.setState( this.setState(
state => { state => {
const { queries, queryTransactions } = state; const { queries, queryTransactions } = state;
@@ -391,16 +392,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
nextQueryTransactions = []; nextQueryTransactions = [];
} else { } else {
// Modify query only at index // Modify query only at index
nextQueries = [ nextQueries = queries.map((q, i) => {
...queries.slice(0, index), // Synchronise all queries with local query cache to ensure consistency
{ q.query = this.queryExpressions[i];
key: generateQueryKey(index), return i === index
query: datasource.modifyQuery(this.queryExpressions[index], action), ? {
}, key: generateQueryKey(index),
...queries.slice(index + 1), query: datasource.modifyQuery(q.query, action),
]; }
// Discard transactions related to row query : q;
nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); });
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
.map(qt => {
if (qt.hints != null && qt.rowIndex === index) {
qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
}
return qt;
})
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
} }
this.queryExpressions = nextQueries.map(q => q.query); this.queryExpressions = nextQueries.map(q => q.query);
return { return {
@@ -408,7 +419,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
}; };
}, },
() => this.onSubmit() // Accepting certain fixes do not result in a well-formed query which should not be submitted
!preventSubmit ? () => this.onSubmit() : null
); );
} }
}; };
@@ -695,11 +707,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}); });
} }
request = url => {
const { datasource } = this.state;
return datasource.metadataRequest(url);
};
cloneState(): ExploreState { cloneState(): ExploreState {
// Copy state, but copy queries including modifications // Copy state, but copy queries including modifications
return { return {
@@ -831,9 +838,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{datasource && !datasourceError ? ( {datasource && !datasourceError ? (
<div className="explore-container"> <div className="explore-container">
<QueryRows <QueryRows
datasource={datasource}
history={history} history={history}
queries={queries} queries={queries}
request={this.request}
onAddQueryRow={this.onAddQueryRow} onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery} onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries} onClickHintFix={this.onModifyQueries}

View File

@@ -0,0 +1,72 @@
import PlaceholdersBuffer from './PlaceholdersBuffer';
describe('PlaceholdersBuffer', () => {
it('does nothing if no placeholders are defined', () => {
const text = 'metric';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe(text);
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of placeholders', () => {
const text = 'sum($2 offset $1) by ($3)';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset ) by ()');
expect(buffer.getNextMoveOffset()).toBe(12);
buffer.setNextPlaceholderValue('1h');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(-10);
buffer.setNextPlaceholderValue('metric');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum(metric offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(16);
buffer.setNextPlaceholderValue('label');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)');
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of adjacent placeholders', () => {
const text = '$1$3$2$4';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('1');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('1');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('2');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('12');
expect(buffer.getNextMoveOffset()).toBe(-1);
buffer.setNextPlaceholderValue('3');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('132');
expect(buffer.getNextMoveOffset()).toBe(1);
buffer.setNextPlaceholderValue('4');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('1324');
expect(buffer.getNextMoveOffset()).toBe(0);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Provides a stateful means of managing placeholders in text.
*
* Placeholders are numbers prefixed with the `$` character (e.g. `$1`).
* Each number value represents the order in which a placeholder should
* receive focus if multiple placeholders exist.
*
* Example scenario given `sum($3 offset $1) by($2)`:
* 1. `sum( offset |) by()`
* 2. `sum( offset 1h) by(|)`
* 3. `sum(| offset 1h) by (label)`
*/
export default class PlaceholdersBuffer {
private nextMoveOffset: number;
private orders: number[];
private parts: string[];
constructor(text: string) {
const result = this.parse(text);
const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0;
this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex);
this.orders = result.orders;
this.parts = result.parts;
}
clearPlaceholders() {
this.nextMoveOffset = 0;
this.orders = [];
}
getNextMoveOffset(): number {
return this.nextMoveOffset;
}
hasPlaceholders(): boolean {
return this.orders.length > 0;
}
setNextPlaceholderValue(value: string) {
if (this.orders.length === 0) {
return;
}
const currentPlaceholderIndex = this.orders[0];
this.parts[currentPlaceholderIndex] = value;
this.orders = this.orders.slice(1);
if (this.orders.length === 0) {
this.nextMoveOffset = 0;
return;
}
const nextPlaceholderIndex = this.orders[0];
// Case should never happen but handle it gracefully in case
if (currentPlaceholderIndex === nextPlaceholderIndex) {
this.nextMoveOffset = 0;
return;
}
const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex;
const indices = backwardMove
? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 }
: { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex };
this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end);
}
toString(): string {
return this.parts.join('');
}
private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) {
return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0);
}
private parse(text: string): ParseResult {
const placeholderRegExp = /\$(\d+)/g;
const parts = [];
const orders = [];
let textOffset = 0;
while (true) {
const match = placeholderRegExp.exec(text);
if (!match) {
break;
}
const part = text.slice(textOffset, match.index);
parts.push(part);
// Accounts for placeholders at text boundaries
if (part !== '') {
parts.push('');
}
const order = parseInt(match[1], 10);
orders.push({ index: parts.length - 1, order });
textOffset += part.length + match.length;
}
// Ensures string serialisation still works if no placeholders were parsed
// and also accounts for the remainder of text with placeholders
parts.push(text.slice(textOffset));
return {
// Placeholder values do not necessarily appear sequentially so sort the
// indices to traverse in priority order
orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index),
parts,
};
}
}
type ParseResult = {
/**
* Indices to placeholder items in `parts` in traversal order.
*/
orders: number[];
/**
* Parts comprising the original text with placeholders occupying distinct items.
*/
parts: string[];
};

View File

@@ -1,231 +1,4 @@
import React from 'react'; import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import Plain from 'slate-plain-serializer';
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
Enzyme.configure({ adapter: new Adapter() });
describe('PromQueryField typeahead handling', () => {
const defaultProps = {
request: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({
anchorOffset: 36,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{}': ['label'] }}
labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('{label!=}');
const range = value.selection.merge({ anchorOffset: 8 });
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '!=',
prefix: '',
wrapperClasses: ['context-labels'],
labelKey: 'label',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
label: 'Label values for "label"',
},
]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{__name__="metric"}': ['bar'] }}
labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('metric{bar=ba}');
const range = value.selection.merge({
anchorOffset: 13,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
labelKey: 'bar',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on aggregation context and metric w/o selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric) by ()');
const range = value.selection.merge({
anchorOffset: 16,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
});
});
describe('groupMetricsByPrefix()', () => { describe('groupMetricsByPrefix()', () => {
it('returns an empty group for no metrics', () => { it('returns an empty group for no metrics', () => {

View File

@@ -1,67 +1,23 @@
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment';
import React from 'react'; import React from 'react';
import { Value } from 'slate';
import Cascader from 'rc-cascader'; import Cascader from 'rc-cascader';
import PluginPrism from 'slate-prism'; import PluginPrism from 'slate-prism';
import Prism from 'prismjs'; import Prism from 'prismjs';
import { TypeaheadOutput } from 'app/types/explore';
// dom also includes Element polyfills // dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom'; import { getNextCharacter, getPreviousCousin } from './utils/dom';
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
import BracesPlugin from './slate-plugins/braces'; import BracesPlugin from './slate-plugins/braces';
import RunnerPlugin from './slate-plugins/runner'; import RunnerPlugin from './slate-plugins/runner';
import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
import TypeaheadField, { import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
Suggestion,
SuggestionGroup,
TypeaheadInput,
TypeaheadFieldState,
TypeaheadOutput,
} from './QueryField';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_GROUP = '__histograms__'; const HISTOGRAM_GROUP = '__histograms__';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric'; const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql'; const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__'; export const RECORDING_RULES_GROUP = '__recording_rules__';
export const wrapLabel = (label: string) => ({ label });
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
suggestion.move = -1;
return suggestion;
};
// Syntax highlighting
Prism.languages[PRISM_SYNTAX] = PrismPromql;
function setPrismTokens(language, field, values, alias = 'variable') {
Prism.languages[language][field] = {
alias,
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
};
}
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] { export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option // Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/; const ruleRegex = /:\w+:/;
@@ -133,48 +89,36 @@ interface CascaderOption {
} }
interface PromQueryFieldProps { interface PromQueryFieldProps {
datasource: any;
error?: string; error?: string;
hint?: any; hint?: any;
histogramMetrics?: string[];
history?: any[]; history?: any[];
initialQuery?: string | null; initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
metricsByPrefix?: CascaderOption[]; metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void; onClickHintFix?: (action: any) => void;
onPressEnter?: () => void; onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void; onQueryChange?: (value: string, override?: boolean) => void;
portalOrigin?: string;
request?: (url: string) => any;
supportsLogs?: boolean; // To be removed after Logging gets its own query field supportsLogs?: boolean; // To be removed after Logging gets its own query field
} }
interface PromQueryFieldState { interface PromQueryFieldState {
histogramMetrics: string[];
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[]; logLabelOptions: any[];
metrics: string[];
metricsOptions: any[]; metricsOptions: any[];
metricsByPrefix: CascaderOption[]; metricsByPrefix: CascaderOption[];
syntaxLoaded: boolean; syntaxLoaded: boolean;
} }
interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> { class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[]; plugins: any[];
languageProvider: any;
constructor(props: PromQueryFieldProps, context) { constructor(props: PromQueryFieldProps, context) {
super(props, context); super(props, context);
if (props.datasource.languageProvider) {
this.languageProvider = props.datasource.languageProvider;
}
this.plugins = [ this.plugins = [
BracesPlugin(), BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }), RunnerPlugin({ handler: props.onPressEnter }),
@@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
]; ];
this.state = { this.state = {
histogramMetrics: props.histogramMetrics || [],
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
logLabelOptions: [], logLabelOptions: [],
metrics: props.metrics || [], metricsByPrefix: [],
metricsByPrefix: props.metricsByPrefix || [],
metricsOptions: [], metricsOptions: [],
syntaxLoaded: false, syntaxLoaded: false,
}; };
} }
componentDidMount() { componentDidMount() {
// Temporarily reused by logging if (this.languageProvider) {
const { supportsLogs } = this.props; this.languageProvider.start().then(() => this.onReceiveMetrics());
if (supportsLogs) {
this.fetchLogLabels();
} else {
// Usual actions
this.fetchMetricNames();
this.fetchHistogramMetrics();
} }
} }
@@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
}; };
onReceiveMetrics = () => { onReceiveMetrics = () => {
const { histogramMetrics, metrics, metricsByPrefix } = this.state; const { histogramMetrics, metrics } = this.languageProvider;
if (!metrics) { if (!metrics) {
return; return;
} }
// Update global prism config Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics); Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
alias: 'variable',
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
};
// Build metrics tree // Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics);
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm })); const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [ const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions }, { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
@@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
}; };
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
if (!this.languageProvider) {
return { suggestions: [] };
}
const { history } = this.props;
const { prefix, text, value, wrapperNode } = typeahead; const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context // Get DOM-dependent context
@@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const labelKey = labelKeyNode && labelKeyNode.textContent; const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter(); const nextChar = getNextCharacter();
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey }); const result = this.languageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey },
{ history }
);
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result; return result;
}; };
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeTypeahead();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelTypeahead.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
// sum(foo{bar="1"}) by (|)
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// sum(foo{bar="1"}) by (
const leftSide = line.slice(0, cursorOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
fetchHistogramMetrics() {
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics }, this.onReceiveMetrics);
}
});
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.state.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
const logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_SELECTOR]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
const labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, callback);
} catch (e) {
console.error(e);
}
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const metrics = body.data;
const metricsByPrefix = groupMetricsByPrefix(metrics);
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() { render() {
const { error, hint, initialQuery, supportsLogs } = this.props; const { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state; const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
return ( return (
<div className="prom-query-field"> <div className="prom-query-field">

View File

@@ -5,95 +5,28 @@ import { Change, Value } from 'slate';
import { Editor } from 'slate-react'; import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import ClearPlugin from './slate-plugins/clear'; import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline'; import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead'; import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value'; import { makeFragment, makeValue } from './Value';
import PlaceholdersBuffer from './PlaceholdersBuffer';
export const TYPEAHEAD_DEBOUNCE = 100; export const TYPEAHEAD_DEBOUNCE = 100;
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion { function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups // Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex]; return flattenedSuggestions[correctedIndex];
} }
function hasSuggestions(suggestions: SuggestionGroup[]): boolean { function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0; return suggestions && suggestions.length > 0;
} }
export interface Suggestion {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface SuggestionGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: Suggestion[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface TypeaheadFieldProps { interface TypeaheadFieldProps {
additionalPlugins?: any[]; additionalPlugins?: any[];
cleanText?: (text: string) => string; cleanText?: (text: string) => string;
@@ -110,7 +43,7 @@ interface TypeaheadFieldProps {
} }
export interface TypeaheadFieldState { export interface TypeaheadFieldState {
suggestions: SuggestionGroup[]; suggestions: CompletionItemGroup[];
typeaheadContext: string | null; typeaheadContext: string | null;
typeaheadIndex: number; typeaheadIndex: number;
typeaheadPrefix: string; typeaheadPrefix: string;
@@ -127,20 +60,17 @@ export interface TypeaheadInput {
wrapperNode: Element; wrapperNode: Element;
} }
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: SuggestionGroup[];
}
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> { class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null; menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[]; plugins: any[];
resetTimer: any; resetTimer: any;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
// Base plugins // Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins]; this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
@@ -150,7 +80,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
typeaheadIndex: 0, typeaheadIndex: 0,
typeaheadPrefix: '', typeaheadPrefix: '',
typeaheadText: '', typeaheadText: '',
value: makeValue(props.initialValue || '', props.syntax), value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
}; };
} }
@@ -175,12 +105,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
componentWillReceiveProps(nextProps: TypeaheadFieldProps) { componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) { if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded // Need a bogus edit to re-render the editor after syntax has fully loaded
this.onChange( const change = this.state.value
this.state.value .change()
.change() .insertText(' ')
.insertText(' ') .deleteBackward();
.deleteBackward() if (this.placeholdersBuffer.hasPlaceholders()) {
); change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
this.onChange(change);
} }
} }
@@ -293,7 +225,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
} }
}, TYPEAHEAD_DEBOUNCE); }, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change: Change, suggestion: Suggestion): Change { applyTypeahead(change: Change, suggestion: CompletionItem): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props; const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state; const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label; let suggestionText = suggestion.insertText || suggestion.label;
@@ -363,7 +295,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
} }
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex); const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
this.applyTypeahead(change, suggestion); const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
if (insertTextOperation) {
const suggestionText = insertTextOperation.text;
this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
if (this.placeholdersBuffer.hasPlaceholders()) {
nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
}
return true; return true;
} }
break; break;
@@ -410,6 +352,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
// If we dont wait here, menu clicks wont work because the menu // If we dont wait here, menu clicks wont work because the menu
// will be gone. // will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100); this.resetTimer = setTimeout(this.resetTypeahead, 100);
// Disrupting placeholder entry wipes all remaining placeholders needing input
this.placeholdersBuffer.clearPlaceholders();
if (onBlur) { if (onBlur) {
onBlur(); onBlur();
} }
@@ -422,7 +366,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
} }
}; };
onClickMenu = (item: Suggestion) => { onClickMenu = (item: CompletionItem) => {
// Manually triggering change // Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item); const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change); this.onChange(change);

View File

@@ -1,12 +1,12 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { QueryTransaction } from 'app/types/explore'; import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
// TODO make this datasource-plugin-dependent // TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField'; import QueryField from './PromQueryField';
import QueryTransactions from './QueryTransactions'; import QueryTransactions from './QueryTransactions';
function getFirstHintFromTransactions(transactions: QueryTransaction[]) { function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
if (transaction) { if (transaction) {
return transaction.hints[0]; return transaction.hints[0];
@@ -14,7 +14,30 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
return undefined; return undefined;
} }
class QueryRow extends PureComponent<any, {}> { interface QueryRowEventHandlers {
onAddQueryRow: (index: number) => void;
onChangeQuery: (value: string, index: number, override?: boolean) => void;
onClickHintFix: (action: object, index?: number) => void;
onExecuteQuery: () => void;
onRemoveQueryRow: (index: number) => void;
}
interface QueryRowCommonProps {
className?: string;
datasource: any;
history: HistoryItem[];
// Temporarily
supportsLogs?: boolean;
transactions: QueryTransaction[];
}
type QueryRowProps = QueryRowCommonProps &
QueryRowEventHandlers & {
index: number;
query: string;
};
class QueryRow extends PureComponent<QueryRowProps> {
onChangeQuery = (value, override?: boolean) => { onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props; const { index, onChangeQuery } = this.props;
if (onChangeQuery) { if (onChangeQuery) {
@@ -55,8 +78,8 @@ class QueryRow extends PureComponent<any, {}> {
}; };
render() { render() {
const { history, query, request, supportsLogs, transactions } = this.props; const { datasource, history, query, supportsLogs, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error); const transactionWithError = transactions.find(t => t.error !== undefined);
const hint = getFirstHintFromTransactions(transactions); const hint = getFirstHintFromTransactions(transactions);
const queryError = transactionWithError ? transactionWithError.error : null; const queryError = transactionWithError ? transactionWithError.error : null;
return ( return (
@@ -66,6 +89,7 @@ class QueryRow extends PureComponent<any, {}> {
</div> </div>
<div className="query-row-field"> <div className="query-row-field">
<QueryField <QueryField
datasource={datasource}
error={queryError} error={queryError}
hint={hint} hint={hint}
initialQuery={query} initialQuery={query}
@@ -73,7 +97,6 @@ class QueryRow extends PureComponent<any, {}> {
onClickHintFix={this.onClickHintFix} onClickHintFix={this.onClickHintFix}
onPressEnter={this.onPressEnter} onPressEnter={this.onPressEnter}
onQueryChange={this.onChangeQuery} onQueryChange={this.onChangeQuery}
request={request}
supportsLogs={supportsLogs} supportsLogs={supportsLogs}
/> />
</div> </div>
@@ -93,9 +116,14 @@ class QueryRow extends PureComponent<any, {}> {
} }
} }
export default class QueryRows extends PureComponent<any, {}> { type QueryRowsProps = QueryRowCommonProps &
QueryRowEventHandlers & {
queries: Query[];
};
export default class QueryRows extends PureComponent<QueryRowsProps> {
render() { render() {
const { className = '', queries, queryHints, transactions, ...handlers } = this.props; const { className = '', queries, transactions, ...handlers } = this.props;
return ( return (
<div className={className}> <div className={className}>
{queries.map((q, index) => ( {queries.map((q, index) => (

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import Highlighter from 'react-highlight-words'; import Highlighter from 'react-highlight-words';
import { Suggestion, SuggestionGroup } from './QueryField'; import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
function scrollIntoView(el: HTMLElement) { function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) { if (!el || !el.offsetParent) {
@@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) {
interface TypeaheadItemProps { interface TypeaheadItemProps {
isSelected: boolean; isSelected: boolean;
item: Suggestion; item: CompletionItem;
onClickItem: (Suggestion) => void; onClickItem: (Suggestion) => void;
prefix?: string; prefix?: string;
} }
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> { class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
el: HTMLElement; el: HTMLElement;
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
} }
interface TypeaheadGroupProps { interface TypeaheadGroupProps {
items: Suggestion[]; items: CompletionItem[];
label: string; label: string;
onClickItem: (Suggestion) => void; onClickItem: (CompletionItem) => void;
selected: Suggestion; selected: CompletionItem;
prefix?: string; prefix?: string;
} }
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> { class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
render() { render() {
const { items, label, selected, onClickItem, prefix } = this.props; const { items, label, selected, onClickItem, prefix } = this.props;
return ( return (
@@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
} }
interface TypeaheadProps { interface TypeaheadProps {
groupedItems: SuggestionGroup[]; groupedItems: CompletionItemGroup[];
menuRef: any; menuRef: any;
selectedItem: Suggestion | null; selectedItem: CompletionItem | null;
onClickItem: (Suggestion) => void; onClickItem: (Suggestion) => void;
prefix?: string; prefix?: string;
} }
class Typeahead extends React.PureComponent<TypeaheadProps, {}> { class Typeahead extends React.PureComponent<TypeaheadProps> {
render() { render() {
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return ( return (

View File

@@ -14,7 +14,7 @@ export class SoloPanelCtrl {
const params = $location.search(); const params = $location.search();
panelId = parseInt(params.panelId, 10); panelId = parseInt(params.panelId, 10);
$scope.onAppEvent('dashboard-initialized', $scope.initPanelScope); appEvents.on('dashboard-initialized', $scope.initPanelScope);
// if no uid, redirect to new route based on slug // if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Label } from 'app/core/components/Forms/Forms'; import { Label } from 'app/core/components/Label/Label';
import { Team } from '../../types'; import { Team } from '../../types';
import { updateTeam } from './state/actions'; import { updateTeam } from './state/actions';
import { getRouteParamsId } from '../../core/selectors/location'; import { getRouteParamsId } from '../../core/selectors/location';

View File

@@ -37,6 +37,9 @@ export default class CloudWatchDatasource {
item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars); item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars);
item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars); item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars);
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map(s => {
return this.templateSrv.replace(s, options.scopedVars);
});
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars); item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars); item.expression = this.templateSrv.replace(item.expression, options.scopedVars);

View File

@@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query'; import PrometheusMetricFindQuery from './metric_find_query';
import { ResultTransformer } from './result_transformer'; import { ResultTransformer } from './result_transformer';
import PrometheusLanguageProvider from './language_provider';
import { BackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query'; import addLabelToQuery from './add_label_to_query';
@@ -60,6 +61,7 @@ export class PrometheusDatasource {
interval: string; interval: string;
queryTimeout: string; queryTimeout: string;
httpMethod: string; httpMethod: string;
languageProvider: PrometheusLanguageProvider;
resultTransformer: ResultTransformer; resultTransformer: ResultTransformer;
/** @ngInject */ /** @ngInject */
@@ -76,6 +78,7 @@ export class PrometheusDatasource {
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET'; this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.resultTransformer = new ResultTransformer(templateSrv); this.resultTransformer = new ResultTransformer(templateSrv);
this.ruleMappings = {}; this.ruleMappings = {};
this.languageProvider = new PrometheusLanguageProvider(this);
} }
init() { init() {
@@ -461,6 +464,9 @@ export class PrometheusDatasource {
case 'ADD_RATE': { case 'ADD_RATE': {
return `rate(${query}[5m])`; return `rate(${query}[5m])`;
} }
case 'ADD_SUM': {
return `sum(${query.trim()}) by ($1)`;
}
case 'EXPAND_RULES': { case 'EXPAND_RULES': {
const mapping = action.mapping; const mapping = action.mapping;
if (mapping) { if (mapping) {

View File

@@ -0,0 +1,347 @@
import _ from 'lodash';
import moment from 'moment';
import {
CompletionItem,
CompletionItemGroup,
LanguageProvider,
TypeaheadInput,
TypeaheadOutput,
} from 'app/types/explore';
import { parseSelector, processLabels, RATE_RANGES } from './language_utils';
import PromqlSyntax, { FUNCTIONS } from './promql';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const wrapLabel = (label: string) => ({ label });
const setFunctionMove = (suggestion: CompletionItem): CompletionItem => {
suggestion.move = -1;
return suggestion;
};
export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export default class PromQlLanguageProvider extends LanguageProvider {
histogramMetrics?: string[];
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
logLabelOptions: any[];
supportsLogs?: boolean;
started: boolean;
constructor(datasource: any, initialValues?: any) {
super();
this.datasource = datasource;
this.histogramMetrics = [];
this.labelKeys = {};
this.labelValues = {};
this.metrics = [];
this.supportsLogs = false;
this.started = false;
Object.assign(this, initialValues);
}
// Strip syntax chars
cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
getSyntax() {
return PromqlSyntax;
}
request = url => {
return this.datasource.metadataRequest(url);
};
start = () => {
if (!this.started) {
this.started = true;
return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]);
}
return Promise.resolve([]);
};
// Keep this DOM-free for testing
provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeCompletionItems();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelCompletionItems.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationCompletionItems.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyCompletionItems(context || {});
}
return {
suggestions: [],
};
}
getEmptyCompletionItems(context: any): TypeaheadOutput {
const { history } = context;
const { metrics } = this;
const suggestions: CompletionItemGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeCompletionItems(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
// Stitch all query lines together to support multi-line queries
let queryOffset;
const queryText = value.document.getBlocks().reduce((text, block) => {
const blockText = block.getText();
if (value.anchorBlock.key === block.key) {
// Newline characters are not accounted for but this is irrelevant
// for the purpose of extracting the selector string
queryOffset = value.anchorOffset + text.length;
}
text += blockText;
return text;
}, '');
const leftSide = queryText.slice(0, queryOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
// Range vector syntax not accounted for by subsequent parse so discard it if present
selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
const labelValues = this.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.labelValues[selector] && !this.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
this.metrics = body.data;
} catch (error) {
console.error(error);
}
}
async fetchHistogramMetrics() {
await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true);
const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
this.histogramMetrics = histogramSeries['__name__'].slice().sort();
}
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
this.logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
this.logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.labelKeys = labelKeysBySelector;
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
this.labelValues = {
...this.labelValues,
[EMPTY_SELECTOR]: values,
};
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
this.labelKeys = {
...this.labelKeys,
[name]: keys,
};
this.labelValues = {
...this.labelValues,
[name]: values,
};
} catch (e) {
console.error(e);
}
}
}

View File

@@ -23,9 +23,6 @@ export function processLabels(labels, withName = false) {
return { values, keys: Object.keys(values) }; return { values, keys: Object.keys(values) };
} }
// Strip syntax chars
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/; const selectorRegexp = /\{[^}]*?\}/;
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;

View File

@@ -1,6 +1,13 @@
import _ from 'lodash'; import _ from 'lodash';
export function getQueryHints(query: string, series?: any[], datasource?: any): any[] { import { QueryHint } from 'app/types/explore';
/**
* Number of time series results needed before starting to suggest sum aggregation hints
*/
export const SUM_HINT_THRESHOLD_COUNT = 20;
export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
const hints = []; const hints = [];
// ..._bucket metric needs a histogram_quantile() // ..._bucket metric needs a histogram_quantile()
@@ -88,5 +95,24 @@ export function getQueryHints(query: string, series?: any[], datasource?: any):
}); });
} }
} }
if (series.length >= SUM_HINT_THRESHOLD_COUNT) {
const simpleMetric = query.trim().match(/^\w+$/);
if (simpleMetric) {
hints.push({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: query,
preventSubmit: true,
},
},
});
}
}
return hints.length > 0 ? hints : null; return hints.length > 0 ? hints : null;
} }

View File

@@ -0,0 +1,273 @@
import Plain from 'slate-plain-serializer';
import LanguageProvider from '../language_provider';
describe('Language completion provider', () => {
const datasource = {
metadataRequest: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
});
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({
anchorOffset: 36,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{}': ['label'] },
labelValues: { '{}': { label: ['a', 'b', 'c'] } },
});
const value = Plain.deserialize('{label!=}');
const range = value.selection.merge({ anchorOffset: 8 });
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '!=',
prefix: '',
wrapperClasses: ['context-labels'],
labelKey: 'label',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
label: 'Label values for "label"',
},
]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['bar'] },
labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
});
const value = Plain.deserialize('metric{bar=ba}');
const range = value.selection.merge({
anchorOffset: 13,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
labelKey: 'bar',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on aggregation context and metric w/o selector', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
const value = Plain.deserialize('sum(metric) by ()');
const range = value.selection.merge({
anchorOffset: 16,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions inside a multi-line aggregation context', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
const aggregationTextBlock = value.document.getBlocksAsArray()[3];
const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 });
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric[1h])) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector and label', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
const range = value.selection.merge({
anchorOffset: 42,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
});
});

View File

@@ -1,4 +1,4 @@
import { parseSelector } from './prometheus'; import { parseSelector } from '../language_utils';
describe('parseSelector()', () => { describe('parseSelector()', () => {
let parsed; let parsed;

View File

@@ -1,4 +1,4 @@
import { getQueryHints } from '../query_hints'; import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints';
describe('getQueryHints()', () => { describe('getQueryHints()', () => {
it('returns no hints for no series', () => { it('returns no hints for no series', () => {
@@ -79,4 +79,25 @@ describe('getQueryHints()', () => {
}, },
}); });
}); });
it('returns a sum hint when many time series results are returned for a simple metric', () => {
const seriesCount = SUM_HINT_THRESHOLD_COUNT;
const series = Array.from({ length: seriesCount }, _ => ({
datapoints: [[0, 0], [0, 0]],
}));
const hints = getQueryHints('metric', series);
expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: 'metric',
preventSubmit: true,
},
},
});
});
}); });

View File

@@ -114,7 +114,6 @@ export default class StackdriverDatasource {
if (!queryRes.series) { if (!queryRes.series) {
return; return;
} }
this.projectName = queryRes.meta.defaultProject;
const unit = this.resolvePanelUnitFromTargets(options.targets); const unit = this.resolvePanelUnitFromTargets(options.targets);
queryRes.series.forEach(series => { queryRes.series.forEach(series => {
let timeSerie: any = { let timeSerie: any = {

View File

@@ -5,6 +5,7 @@ import React, { PureComponent } from 'react';
// Components // Components
import Graph from 'app/viz/Graph'; import Graph from 'app/viz/Graph';
import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
import { Switch } from 'app/core/components/Switch/Switch';
// Types // Types
import { PanelProps, NullValueMode } from 'app/types'; import { PanelProps, NullValueMode } from 'app/types';
@@ -35,8 +36,15 @@ export class Graph2 extends PureComponent<Props> {
} }
export class TextOptions extends PureComponent<any> { export class TextOptions extends PureComponent<any> {
onChange = () => {};
render() { render() {
return <p>Text2 Options component</p>; return (
<div className="section gf-form-group">
<h5 className="section-heading">Draw Modes</h5>
<Switch label="Lines" checked={true} onChange={this.onChange} />
</div>
);
} }
} }

View File

@@ -3,6 +3,8 @@
"name": "React Graph", "name": "React Graph",
"id": "graph2", "id": "graph2",
"state": "alpha",
"info": { "info": {
"author": { "author": {
"name": "Grafana Project", "name": "Grafana Project",

View File

@@ -1,33 +1,83 @@
<?xml version="1.0" encoding="iso-8859-1"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- 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" <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="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"> width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.26;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:none;stroke:url(#SVGID_6_);stroke-miterlimit:10;}
</style>
<g> <g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="32.3342" y1="95.7019" x2="32.3342" y2="5.2695"> <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="50" y1="65.6698" x2="50" y2="93.5681">
<stop offset="0" style="stop-color:#FFDE17"/> <stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="0.0803" style="stop-color:#FFD210"/> <stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1774" style="stop-color:#FEC90D"/> <stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/> <stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.6685" style="stop-color:#F3903F"/> <stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.8876" style="stop-color:#ED683C"/> <stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="1" style="stop-color:#E93E3A"/> <stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient> </linearGradient>
<path style="fill:url(#SVGID_1_);" d="M48.173,57.757V39.825c0-1.302,1.055-2.357,2.357-2.357h9.691 <path class="st0" d="M97.6,83.8H2.4c-1.3,0-2.4-1.1-2.4-2.4v-1.8l17-1l19.2-4.3l16.3-1.6l16.5,0l15.8-4.7l15.1-3v16.3
c0.897,0,1.346-1.084,0.712-1.718L34.112,0.737c-0.982-0.982-2.574-0.982-3.556,0L3.735,35.75 C100,82.8,98.9,83.8,97.6,83.8z"/>
c-0.634,0.634-0.185,1.718,0.712,1.718h9.691c1.302,0,2.357,1.055,2.357,2.357v17.932c0,0.958,0.776,1.734,1.734,1.734h28.21 <g>
C47.397,59.491,48.173,58.715,48.173,57.757z"/> <linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.098" y1="76.0776" x2="19.098" y2="27.8027">
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="67.6658" y1="94.1706" x2="67.6658" y2="3.7383"> <stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="0" style="stop-color:#FFDE17"/> <stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.0803" style="stop-color:#FFD210"/> <stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1774" style="stop-color:#FEC90D"/> <stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/> <stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F3903F"/> <stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/> <stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E93E3A"/> <stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st1" d="M19.6,64.3V38.9l-5.2,3.9l-3.5-6l9.4-6.9h6.8v34.4H19.6z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="42.412" y1="76.0776" x2="42.412" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st2" d="M53.1,39.4c0,1.1-0.1,2.2-0.4,3.2c-0.3,1-0.7,1.9-1.2,2.8c-0.5,0.9-1,1.7-1.7,2.5c-0.6,0.8-1.2,1.6-1.9,2.3
l-6.4,7.4h11.1v6.7H32.3v-6.9l10.5-12c0.8-1,1.5-2,2-3c0.5-1,0.7-2,0.7-2.9c0-1-0.2-1.9-0.7-2.6c-0.5-0.7-1.2-1.1-2.2-1.1
c-0.9,0-1.7,0.4-2.3,1.1c-0.6,0.8-1,1.9-1.1,3.3l-7.3-0.7c0.4-3.5,1.6-6.1,3.6-7.9c2-1.7,4.5-2.6,7.4-2.6c1.6,0,3,0.2,4.3,0.7
c1.3,0.5,2.3,1.2,3.2,2c0.9,0.9,1.6,1.9,2.1,3.2C52.8,36.4,53.1,37.8,53.1,39.4z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="60.3739" y1="76.0776" x2="60.3739" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st3" d="M64.5,60.4c0,1.2-0.4,2.3-1.2,3.1c-0.8,0.8-1.8,1.3-3,1.3c-1.2,0-2.2-0.4-3-1.3c-0.8-0.8-1.1-1.9-1.1-3.1
c0-1.2,0.4-2.2,1.1-3.1c0.8-0.9,1.8-1.3,3-1.3c1.2,0,2.2,0.4,3,1.3C64.1,58.1,64.5,59.2,64.5,60.4z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="77.5234" y1="76.0776" x2="77.5234" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st4" d="M85.5,57.4v6.9h-6.9v-6.9H66v-6.6l10.1-20.9h9.4V51H89v6.4H85.5z M78.8,37.5L78.8,37.5l-6,13.5h6V37.5z"/>
</g>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-2.852199e-02" y1="72.3985" x2="100.0976" y2="72.3985">
<stop offset="0" style="stop-color:#F28F3F"/>
<stop offset="1" style="stop-color:#F28F3F"/>
</linearGradient> </linearGradient>
<path style="fill:url(#SVGID_2_);" d="M95.553,62.532h-9.691c-1.302,0-2.357-1.055-2.357-2.357V42.243 <polyline class="st5" points="0,79.7 17,78.7 36.2,74.4 52.5,72.8 69,72.9 84.9,68.1 100,65.1 "/>
c0-0.958-0.776-1.734-1.734-1.734h-28.21c-0.958,0-1.734,0.776-1.734,1.734v17.932c0,1.302-1.055,2.357-2.357,2.357h-9.691
c-0.897,0-1.346,1.084-0.712,1.718l26.821,35.013c0.982,0.982,2.574,0.982,3.556,0L96.265,64.25
C96.898,63.616,96.45,62.532,95.553,62.532z"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -17,7 +17,6 @@ export class GrafanaCtrl {
/** @ngInject */ /** @ngInject */
constructor( constructor(
$scope, $scope,
alertSrv,
utilSrv, utilSrv,
$rootScope, $rootScope,
$controller, $controller,
@@ -41,11 +40,8 @@ export class GrafanaCtrl {
$scope._ = _; $scope._ = _;
profiler.init(config, $rootScope); profiler.init(config, $rootScope);
alertSrv.init();
utilSrv.init(); utilSrv.init();
bridgeSrv.init(); bridgeSrv.init();
$scope.dashAlerts = alertSrv;
}; };
$rootScope.colors = colors; $rootScope.colors = colors;

View File

@@ -0,0 +1,25 @@
export interface AppNotification {
id?: number;
severity: AppNotificationSeverity;
icon: string;
title: string;
text: string;
timeout: AppNotificationTimeout;
}
export enum AppNotificationSeverity {
Success = 'success',
Warning = 'warning',
Error = 'error',
Info = 'info',
}
export enum AppNotificationTimeout {
Warning = 5000,
Success = 3000,
Error = 7000,
}
export interface AppNotificationsState {
appNotifications: AppNotification[];
}

View File

@@ -1,3 +1,75 @@
import { Value } from 'slate';
export interface CompletionItem {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface CompletionItemGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: CompletionItem[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface ExploreDatasource { interface ExploreDatasource {
value: string; value: string;
label: string; label: string;
@@ -8,6 +80,26 @@ export interface HistoryItem {
query: string; query: string;
} }
export abstract class LanguageProvider {
datasource: any;
request: (url) => Promise<any>;
start: () => Promise<any>;
}
export interface TypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: CompletionItemGroup[];
}
export interface Range { export interface Range {
from: string; from: string;
to: string; to: string;
@@ -18,11 +110,29 @@ export interface Query {
key?: string; key?: string;
} }
export interface QueryFix {
type: string;
label: string;
action?: QueryFixAction;
}
export interface QueryFixAction {
type: string;
query?: string;
preventSubmit?: boolean;
}
export interface QueryHint {
type: string;
label: string;
fix?: QueryFix;
}
export interface QueryTransaction { export interface QueryTransaction {
id: string; id: string;
done: boolean; done: boolean;
error?: string; error?: string;
hints?: any[]; hints?: QueryHint[];
latency: number; latency: number;
options: any; options: any;
query: string; query: string;

View File

@@ -23,6 +23,12 @@ import {
import { PanelProps } from './panel'; import { PanelProps } from './panel';
import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins'; import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
import { Organization, OrganizationPreferences, OrganizationState } from './organization'; import { Organization, OrganizationPreferences, OrganizationState } from './organization';
import {
AppNotification,
AppNotificationSeverity,
AppNotificationsState,
AppNotificationTimeout,
} from './appNotifications';
export { export {
Team, Team,
@@ -74,6 +80,10 @@ export {
Organization, Organization,
OrganizationState, OrganizationState,
OrganizationPreferences, OrganizationPreferences,
AppNotification,
AppNotificationsState,
AppNotificationSeverity,
AppNotificationTimeout,
}; };
export interface StoreState { export interface StoreState {
@@ -87,4 +97,5 @@ export interface StoreState {
dataSources: DataSourcesState; dataSources: DataSourcesState;
users: UsersState; users: UsersState;
organization: OrganizationState; organization: OrganizationState;
appNotifications: AppNotificationsState;
} }

View File

@@ -7,13 +7,13 @@
.alert { .alert {
padding: 1.25rem 2rem 1.25rem 1.5rem; padding: 1.25rem 2rem 1.25rem 1.5rem;
margin-bottom: $line-height-base; margin-bottom: $panel-margin / 2;
text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5); text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5);
background: $alert-error-bg; background: $alert-error-bg;
position: relative; position: relative;
color: $white; color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
border-radius: 2px; border-radius: $border-radius;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }

View File

@@ -21,6 +21,9 @@ div.flot-text {
height: 100%; height: 100%;
&--solo { &--solo {
position: fixed;
bottom: 0;
right: 0;
margin: 0; margin: 0;
.panel-container { .panel-container {
border: none; border: none;

View File

@@ -2271,9 +2271,51 @@ Licensed under the MIT license.
}); });
} }
function drawOrphanedPoints(series) {
/* Filters series data for points with no neighbors before or after
* and plots single 0.5 radius points for them so that they are displayed.
*/
var abandonedPoints = [];
var beforeX = null;
var afterX = null;
var datapoints = series.datapoints;
// find any points with no neighbors before or after
var emptyPoints = [];
for (var j = 0; j < datapoints.pointsize - 2; j++) {
emptyPoints.push(0);
}
for (var i = 0; i < datapoints.points.length; i += datapoints.pointsize) {
var x = datapoints.points[i], y = datapoints.points[i + 1];
if (i === datapoints.points.length - datapoints.pointsize) {
afterX = null;
} else {
afterX = datapoints.points[i + datapoints.pointsize];
}
if (x !== null && y !== null && beforeX === null && afterX === null) {
abandonedPoints.push(x);
abandonedPoints.push(y);
abandonedPoints.push.apply(abandonedPoints, emptyPoints);
}
beforeX = x;
}
var olddatapoints = datapoints.points
datapoints.points = abandonedPoints;
series.points.radius = series.lines.lineWidth/2;
// plot the orphan points with a radius of lineWidth/2
drawSeriesPoints(series);
// reset old info
datapoints.points = olddatapoints;
}
function drawSeries(series) { function drawSeries(series) {
if (series.lines.show) if (series.lines.show)
drawSeriesLines(series); drawSeriesLines(series);
if (!series.points.show && !series.bars.show) {
// not necessary if user wants points displayed for everything
drawOrphanedPoints(series);
}
if (series.bars.show) if (series.bars.show)
drawSeriesBars(series); drawSeriesBars(series);
if (series.points.show) if (series.points.show)

View File

@@ -14,6 +14,9 @@
<link rel="icon" type="image/png" href="public/img/fav32.png"> <link rel="icon" type="image/png" href="public/img/fav32.png">
<link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28"> <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
<link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png">
<link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="msapplication-TileColor" content="#2b5797"> <meta name="msapplication-TileColor" content="#2b5797">
@@ -23,13 +26,6 @@
<body class="theme-[[ .Theme ]]"> <body class="theme-[[ .Theme ]]">
<style> <style>
body {
margin: 0;
height: 100%;
width: 100%;
position: absolute;
}
.preloader { .preloader {
height: 100%; height: 100%;
flex-direction: column; flex-direction: column;
@@ -38,14 +34,6 @@
align-items: center; align-items: center;
} }
.theme-light .preloader {
background: linear-gradient(-60deg, #f7f8fa, #f5f6f9 70%, #f7f8fa 98%);
}
.theme-dark .preloader {
background: linear-gradient(180deg, #222426 10px, #161719 100px);
}
.preloader__enter { .preloader__enter {
opacity: 0; opacity: 0;
animation-name: preloader-fade-in; animation-name: preloader-fade-in;
@@ -200,21 +188,8 @@
<grafana-app class="grafana-app" ng-cloak> <grafana-app class="grafana-app" ng-cloak>
<sidemenu class="sidemenu"></sidemenu> <sidemenu class="sidemenu"></sidemenu>
<app-notifications-list class="page-alert-list"></app-notifications-list>
<div class="page-alert-list">
<div ng-repeat='alert in dashAlerts.list' class="alert-{{alert.severity}} alert">
<div class="alert-icon">
<i class="{{alert.icon}}"></i>
</div>
<div class="alert-body">
<div class="alert-title">{{alert.title}}</div>
<div class="alert-text" ng-bind='alert.text'></div>
</div>
<button type="button" class="alert-close" ng-click="dashAlerts.clear(alert)">
<i class="fa fa fa-remove"></i>
</button>
</div>
</div>
<div class="main-view"> <div class="main-view">
<div class="scroll-canvas" page-scrollbar> <div class="scroll-canvas" page-scrollbar>
@@ -266,14 +241,7 @@
navTree: [[.NavTree]] navTree: [[.NavTree]]
}; };
// load css async // In case the js files fails to load the code below will show an info message.
var myCSS = document.createElement("link");
myCSS.rel = "stylesheet";
myCSS.href = "public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]";
// insert it at the end of the head in a legacy-friendly manner
document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling);
// switch loader to show all has loaded
window.onload = function() { window.onload = function() {
var preloader = document.getElementsByClassName("preloader"); var preloader = document.getElementsByClassName("preloader");
if (preloader.length) { if (preloader.length) {

View File

@@ -76,7 +76,7 @@ if [ -d '/tmp/phantomjs/windows' ]; then
cp /tmp/phantomjs/windows/phantomjs.exe tools/phantomjs/phantomjs.exe cp /tmp/phantomjs/windows/phantomjs.exe tools/phantomjs/phantomjs.exe
rm tools/phantomjs/phantomjs rm tools/phantomjs/phantomjs
else else
echo 'PhantomJS binaries for darwin missing!' echo 'PhantomJS binaries for Windows missing!'
fi fi
go run build.go -goos windows -pkg-arch amd64 ${OPT} package-only go run build.go -goos windows -pkg-arch amd64 ${OPT} package-only

View File

@@ -22,13 +22,13 @@ var versionRe = regexp.MustCompile(`grafana-(.*)(\.|_)(arm64|armhfp|aarch64|armv
var debVersionRe = regexp.MustCompile(`grafana_(.*)_(arm64|armv7|armhf|amd64)\.deb`) var debVersionRe = regexp.MustCompile(`grafana_(.*)_(arm64|armv7|armhf|amd64)\.deb`)
var builds = []build{} var builds = []build{}
var architectureMapping = map[string]string{ var architectureMapping = map[string]string{
"armv7":"armv7", "armv7": "armv7",
"armhfp":"armv7", "armhfp": "armv7",
"armhf":"armv7", "armhf": "armv7",
"arm64":"arm64", "arm64": "arm64",
"aarch64":"arm64", "aarch64": "arm64",
"amd64":"amd64", "amd64": "amd64",
"x86_64":"amd64", "x86_64": "amd64",
} }
func main() { func main() {
@@ -78,7 +78,7 @@ func mapPackage(path string, name string, shaBytes []byte) (build, error) {
if len(result) > 0 { if len(result) > 0 {
version = string(result[1]) version = string(result[1])
log.Printf("Version detected: %v", version) log.Printf("Version detected: %v", version)
} else if (len(debResult) > 0) { } else if len(debResult) > 0 {
version = string(debResult[1]) version = string(debResult[1])
} else { } else {
return build{}, fmt.Errorf("Unable to figure out version from '%v'", name) return build{}, fmt.Errorf("Unable to figure out version from '%v'", name)
@@ -124,6 +124,9 @@ func mapPackage(path string, name string, shaBytes []byte) (build, error) {
} }
func packageWalker(path string, f os.FileInfo, err error) error { func packageWalker(path string, f os.FileInfo, err error) error {
if err != nil {
log.Printf("error: %v", err)
}
if f.Name() == "dist" || strings.Contains(f.Name(), "sha256") || strings.Contains(f.Name(), "latest") { if f.Name() == "dist" || strings.Contains(f.Name(), "sha256") || strings.Contains(f.Name(), "latest") {
return nil return nil
} }
@@ -134,7 +137,6 @@ func packageWalker(path string, f os.FileInfo, err error) error {
} }
build, err := mapPackage(path, f.Name(), shaBytes) build, err := mapPackage(path, f.Name(), shaBytes)
if err != nil { if err != nil {
log.Printf("Could not map metadata from package: %v", err) log.Printf("Could not map metadata from package: %v", err)
return nil return nil

View File

@@ -4,7 +4,7 @@ module.exports = function(config) {
var task = { var task = {
release: { release: {
options: { options: {
archive: '<%= destDir %>/<%= pkg.name %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.tar.gz' archive: '<%= destDir %>/<%= pkg.name %><%= enterprise ? "-enterprise" : "" %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.tar.gz'
}, },
files : [ files : [
{ {
@@ -23,7 +23,7 @@ module.exports = function(config) {
}; };
if (config.platform === 'windows') { if (config.platform === 'windows') {
task.release.options.archive = '<%= destDir %>/<%= pkg.name %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.zip'; task.release.options.archive = '<%= destDir %>/<%= pkg.name %><%= enterprise ? "-enterprise" : "" %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.zip';
} }
return task; return task;