mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into org-page-to-react
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -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
|
||||||
|
|||||||
13
build.go
13
build.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
packaging/docker/build-enterprise.sh
Executable file
10
packaging/docker/build-enterprise.sh
Executable 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 \
|
||||||
|
.
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
28
public/app/core/actions/appNotification.ts
Normal file
28
public/app/core/actions/appNotification.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -19,3 +19,4 @@ export const Label: SFC<Props> = props => {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
46
public/app/core/components/Switch/Switch.tsx
Normal file
46
public/app/core/components/Switch/Switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
public/app/core/copy/appNotification.ts
Normal file
46
public/app/core/copy/appNotification.ts
Normal 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(),
|
||||||
|
});
|
||||||
51
public/app/core/reducers/appNotification.test.ts
Normal file
51
public/app/core/reducers/appNotification.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
public/app/core/reducers/appNotification.ts
Normal file
19
public/app/core/reducers/appNotification.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
11
public/app/core/utils/connectWithReduxStore.tsx
Normal file
11
public/app/core/utils/connectWithReduxStore.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
72
public/app/features/explore/PlaceholdersBuffer.test.ts
Normal file
72
public/app/features/explore/PlaceholdersBuffer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
public/app/features/explore/PlaceholdersBuffer.ts
Normal file
112
public/app/features/explore/PlaceholdersBuffer.ts
Normal 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[];
|
||||||
|
};
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
347
public/app/plugins/datasource/prometheus/language_provider.ts
Normal file
347
public/app/plugins/datasource/prometheus/language_provider.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseSelector } from './prometheus';
|
import { parseSelector } from '../language_utils';
|
||||||
|
|
||||||
describe('parseSelector()', () => {
|
describe('parseSelector()', () => {
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
|
|||||||
25
public/app/types/appNotifications.ts
Normal file
25
public/app/types/appNotifications.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
42
public/vendor/flot/jquery.flot.js
vendored
42
public/vendor/flot/jquery.flot.js
vendored
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user