mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'upstream/master' into postgres-query-builder
This commit is contained in:
commit
734118de86
@ -246,7 +246,7 @@ workflows:
|
||||
test-and-build:
|
||||
jobs:
|
||||
- build-all:
|
||||
filters: *filter-not-release
|
||||
filters: *filter-only-master
|
||||
- build-enterprise:
|
||||
filters: *filter-only-master
|
||||
- codespell:
|
||||
@ -270,9 +270,7 @@ workflows:
|
||||
- gometalinter
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
filters: *filter-only-master
|
||||
- deploy-enterprise-master:
|
||||
requires:
|
||||
- build-all
|
||||
|
14
CHANGELOG.md
14
CHANGELOG.md
@ -2,6 +2,8 @@
|
||||
|
||||
* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
|
||||
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
|
||||
* **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622)
|
||||
* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
|
||||
|
||||
### Minor
|
||||
|
||||
@ -11,18 +13,28 @@
|
||||
* **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2)
|
||||
* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
|
||||
* **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484)
|
||||
* **Prometheus**: Add $interval, $interval_ms, $range, and $range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597)
|
||||
* **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda)
|
||||
* **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm)
|
||||
* **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $__timeFrom and $__timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
|
||||
* **Postgres**: Escape ssl mode parameter in connectionstring [#12644](https://github.com/grafana/grafana/issues/12644), thx [@yogyrahmawan](https://github.com/yogyrahmawan)
|
||||
* **Github OAuth**: Allow changes of user info at Github to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber)
|
||||
* **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane)
|
||||
* **Units**: Polish złoty currency [#12691](https://github.com/grafana/grafana/pull/12691), thx [@mwegrzynek](https://github.com/mwegrzynek)
|
||||
* **Cloudwatch**: Improved error handling [#12489](https://github.com/grafana/grafana/issues/12489), thx [@mtanda](https://github.com/mtanda)
|
||||
* **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668)
|
||||
* **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731)
|
||||
|
||||
# 5.2.2 (unreleased)
|
||||
# 5.2.2 (2018-07-25)
|
||||
|
||||
### Minor
|
||||
|
||||
* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
|
||||
* **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506)
|
||||
* **Postgres/MySQL/MSSQL**: Fix connection leak [#12636](https://github.com/grafana/grafana/issues/12636) [#9827](https://github.com/grafana/grafana/issues/9827)
|
||||
* **Plugins**: Fix loading of external plugins [#12551](https://github.com/grafana/grafana/issues/12551)
|
||||
* **Dashboard**: Remove unwanted scrollbars in embedded panels [#12589](https://github.com/grafana/grafana/issues/12589)
|
||||
* **Prometheus**: Prevent error using $__interval_ms in query [#12533](https://github.com/grafana/grafana/pull/12533), thx [@mtanda](https://github.com/mtanda)
|
||||
|
||||
# 5.2.1 (2018-06-29)
|
||||
|
||||
|
15
Gopkg.lock
generated
15
Gopkg.lock
generated
@ -32,6 +32,7 @@
|
||||
"aws/credentials/ec2rolecreds",
|
||||
"aws/credentials/endpointcreds",
|
||||
"aws/credentials/stscreds",
|
||||
"aws/csm",
|
||||
"aws/defaults",
|
||||
"aws/ec2metadata",
|
||||
"aws/endpoints",
|
||||
@ -43,6 +44,8 @@
|
||||
"internal/shareddefaults",
|
||||
"private/protocol",
|
||||
"private/protocol/ec2query",
|
||||
"private/protocol/eventstream",
|
||||
"private/protocol/eventstream/eventstreamapi",
|
||||
"private/protocol/query",
|
||||
"private/protocol/query/queryutil",
|
||||
"private/protocol/rest",
|
||||
@ -54,8 +57,8 @@
|
||||
"service/s3",
|
||||
"service/sts"
|
||||
]
|
||||
revision = "c7cd1ebe87257cde9b65112fc876b0339ea0ac30"
|
||||
version = "v1.13.49"
|
||||
revision = "fde4ded7becdeae4d26bf1212916aabba79349b4"
|
||||
version = "v1.14.12"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
@ -424,6 +427,12 @@
|
||||
revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/shurcooL/sanitized_anchor_name"
|
||||
packages = ["."]
|
||||
revision = "86672fcb3f950f35f2e675df2240550f2a50762f"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/smartystreets/assertions"
|
||||
packages = [
|
||||
@ -670,6 +679,6 @@
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "85cc057e0cc074ab5b43bd620772d63d51e07b04e8782fcfe55e6929d2fc40f7"
|
||||
inputs-digest = "cb8e7fd81f23ec987fc4d5dd9d31ae0f1164bc2f30cbea2fe86e0d97dd945beb"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
|
@ -36,7 +36,7 @@ ignored = [
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/aws/aws-sdk-go"
|
||||
version = "1.12.65"
|
||||
version = "1.13.56"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
|
1
build.go
1
build.go
@ -330,6 +330,7 @@ func createPackage(options linuxPackageOptions) {
|
||||
name := "grafana"
|
||||
if enterprise {
|
||||
name += "-enterprise"
|
||||
args = append(args, "--replaces", "grafana")
|
||||
}
|
||||
args = append(args, "--name", name)
|
||||
|
||||
|
@ -72,6 +72,8 @@ email = "email"
|
||||
[[servers.group_mappings]]
|
||||
group_dn = "cn=admins,dc=grafana,dc=org"
|
||||
org_role = "Admin"
|
||||
# To make user an instance admin (Grafana Admin) uncomment line below
|
||||
# grafana_admin = true
|
||||
# The Grafana organization database id, optional, if left out the default org (id 1) will be used
|
||||
# org_id = 1
|
||||
|
||||
|
@ -1,11 +1,16 @@
|
||||
This folder contains useful scripts and configuration for...
|
||||
|
||||
* Configuring datasources in Grafana
|
||||
* Provision example dashboards in Grafana
|
||||
* Run preconfiured datasources as docker containers
|
||||
|
||||
want to know more? run setup!
|
||||
* Configuring dev datasources in Grafana
|
||||
* Configuring dev & test scenarios dashboards.
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
After restarting grafana server there should now be a number of datasources named `gdev-<type>` provisioned as well as a dashboard folder named `gdev dashboards`. This folder contains dashboard & panel features tests dashboards.
|
||||
|
||||
# Dev dashboards
|
||||
|
||||
Please update these dashboards or make new ones as new panels & dashboards features are developed or new bugs are found. The dashboards are located in the `devenv/dev-dashboards` folder.
|
||||
|
||||
|
||||
|
@ -14,6 +14,9 @@ datasources:
|
||||
isDefault: true
|
||||
url: http://localhost:9090
|
||||
|
||||
- name: gdev-testdata
|
||||
type: testdata
|
||||
|
||||
- name: gdev-influxdb
|
||||
type: influxdb
|
||||
access: proxy
|
||||
@ -60,7 +63,8 @@ datasources:
|
||||
url: localhost:5432
|
||||
database: grafana
|
||||
user: grafana
|
||||
password: password
|
||||
secureJsonData:
|
||||
password: password
|
||||
jsonData:
|
||||
sslmode: "disable"
|
||||
|
||||
@ -71,3 +75,4 @@ datasources:
|
||||
authType: credentials
|
||||
defaultRegion: eu-west-2
|
||||
|
||||
|
||||
|
@ -1,592 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": 59,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 9,
|
||||
"panels": [],
|
||||
"title": "Row title",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"id": 12,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
"id": 7,
|
||||
"panels": [],
|
||||
"title": "Row",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
"id": 2,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 6
|
||||
},
|
||||
"id": 13,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 10
|
||||
},
|
||||
"id": 11,
|
||||
"panels": [],
|
||||
"title": "Row title",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 4,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 11
|
||||
},
|
||||
"id": 3,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
],
|
||||
"schemaVersion": 16,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "",
|
||||
"title": "Dashboard with rows",
|
||||
"uid": "1DdOzBNmk",
|
||||
"version": 5
|
||||
}
|
1558
devenv/dev-dashboards/panel_tests_graph.json
Normal file
1558
devenv/dev-dashboards/panel_tests_graph.json
Normal file
File diff suppressed because it is too large
Load Diff
574
devenv/dev-dashboards/panel_tests_singlestat.json
Normal file
574
devenv/dev-dashboards/panel_tests_singlestat.json
Normal file
@ -0,0 +1,574 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 100,
|
||||
"minValue": 0,
|
||||
"show": false,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "postfix",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "prefix",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": false,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": true
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,2,3,4,5"
|
||||
}
|
||||
],
|
||||
"thresholds": "5,10",
|
||||
"title": "prefix 3 ms (green) postfixt + sparkline",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "80%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "avg"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorPrefix": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#d44a3a",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#299c46"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 100,
|
||||
"minValue": 0,
|
||||
"show": false,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 0
|
||||
},
|
||||
"id": 3,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": true,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": true
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,2,3,4,5"
|
||||
}
|
||||
],
|
||||
"thresholds": "5,10",
|
||||
"title": "3 ms (red) + full height sparkline",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "200%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "avg"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": true,
|
||||
"colorPrefix": false,
|
||||
"colorValue": false,
|
||||
"colors": [
|
||||
"#d44a3a",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#299c46"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 100,
|
||||
"minValue": 0,
|
||||
"show": false,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 0
|
||||
},
|
||||
"id": 4,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": true,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": false
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,2,3,4,5"
|
||||
}
|
||||
],
|
||||
"thresholds": "5,10",
|
||||
"title": "3 ms + red background",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "200%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "avg"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorPrefix": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 150,
|
||||
"minValue": 0,
|
||||
"show": true,
|
||||
"thresholdLabels": true,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 5,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": true,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": false
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "10,20,80"
|
||||
}
|
||||
],
|
||||
"thresholds": "81,90",
|
||||
"title": "80 ms green gauge, thresholds 81, 90",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "80%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "current"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorPrefix": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 150,
|
||||
"minValue": 0,
|
||||
"show": true,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 7
|
||||
},
|
||||
"id": 6,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": true,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": false
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "10,20,80"
|
||||
}
|
||||
],
|
||||
"thresholds": "81,90",
|
||||
"title": "80 ms green gauge, thresholds 81, 90, no labels",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "80%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "current"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorPrefix": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"decimals": null,
|
||||
"description": "",
|
||||
"format": "ms",
|
||||
"gauge": {
|
||||
"maxValue": 150,
|
||||
"minValue": 0,
|
||||
"show": true,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": false
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 7
|
||||
},
|
||||
"id": 7,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": true,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": false
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "10,20,80"
|
||||
}
|
||||
],
|
||||
"thresholds": "81,90",
|
||||
"title": "80 ms green gauge, thresholds 81, 90, no markers or labels",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "80%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "current"
|
||||
}
|
||||
],
|
||||
"refresh": false,
|
||||
"revision": 8,
|
||||
"schemaVersion": 16,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "Panel Tests - Singlestat",
|
||||
"uid": "singlestat",
|
||||
"version": 14
|
||||
}
|
453
devenv/dev-dashboards/panel_tests_table.json
Normal file
453
devenv/dev-dashboards/panel_tests_table.json
Normal file
@ -0,0 +1,453 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"columns": [],
|
||||
"datasource": "gdev-testdata",
|
||||
"fontSize": "100%",
|
||||
"gridPos": {
|
||||
"h": 11,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 3,
|
||||
"links": [],
|
||||
"pageSize": 10,
|
||||
"scroll": true,
|
||||
"showHeader": true,
|
||||
"sort": {
|
||||
"col": 0,
|
||||
"desc": true
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"alias": "Time",
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"pattern": "Time",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "cell",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorCell",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "currencyUSD"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "value",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorValue",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "Bps"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": null,
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"decimals": 2,
|
||||
"pattern": "/.*/",
|
||||
"thresholds": [],
|
||||
"type": "number",
|
||||
"unit": "short"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"alias": "server1",
|
||||
"expr": "",
|
||||
"format": "table",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0,20,10"
|
||||
},
|
||||
{
|
||||
"alias": "server2",
|
||||
"refId": "B",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0"
|
||||
}
|
||||
],
|
||||
"title": "Time series to rows (2 pages)",
|
||||
"transform": "timeseries_to_rows",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"text": "Avg",
|
||||
"value": "avg"
|
||||
},
|
||||
{
|
||||
"text": "Max",
|
||||
"value": "max"
|
||||
},
|
||||
{
|
||||
"text": "Current",
|
||||
"value": "current"
|
||||
}
|
||||
],
|
||||
"datasource": "gdev-testdata",
|
||||
"fontSize": "100%",
|
||||
"gridPos": {
|
||||
"h": 11,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 4,
|
||||
"links": [],
|
||||
"pageSize": 10,
|
||||
"scroll": true,
|
||||
"showHeader": true,
|
||||
"sort": {
|
||||
"col": 0,
|
||||
"desc": true
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"alias": "Time",
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"pattern": "Time",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "cell",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorCell",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "currencyUSD"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "value",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorValue",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "Bps"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": null,
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"decimals": 2,
|
||||
"pattern": "/.*/",
|
||||
"thresholds": [],
|
||||
"type": "number",
|
||||
"unit": "short"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"alias": "server1",
|
||||
"expr": "",
|
||||
"format": "table",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0,20,10"
|
||||
},
|
||||
{
|
||||
"alias": "server2",
|
||||
"refId": "B",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0"
|
||||
}
|
||||
],
|
||||
"title": "Time series aggregations",
|
||||
"transform": "timeseries_aggregations",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"columns": [],
|
||||
"datasource": "gdev-testdata",
|
||||
"fontSize": "100%",
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 5,
|
||||
"links": [],
|
||||
"pageSize": null,
|
||||
"scroll": true,
|
||||
"showHeader": true,
|
||||
"sort": {
|
||||
"col": 0,
|
||||
"desc": true
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"alias": "Time",
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"pattern": "Time",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "row",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "/Color/",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "currencyUSD"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": null,
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"decimals": 2,
|
||||
"pattern": "/.*/",
|
||||
"thresholds": [],
|
||||
"type": "number",
|
||||
"unit": "short"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"alias": "ColorValue",
|
||||
"expr": "",
|
||||
"format": "table",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0,20,10"
|
||||
}
|
||||
],
|
||||
"title": "color row by threshold",
|
||||
"transform": "timeseries_to_columns",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"columns": [],
|
||||
"datasource": "gdev-testdata",
|
||||
"fontSize": "100%",
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 18
|
||||
},
|
||||
"id": 2,
|
||||
"links": [],
|
||||
"pageSize": null,
|
||||
"scroll": true,
|
||||
"showHeader": true,
|
||||
"sort": {
|
||||
"col": 0,
|
||||
"desc": true
|
||||
},
|
||||
"styles": [
|
||||
{
|
||||
"alias": "Time",
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"pattern": "Time",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "cell",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorCell",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "currencyUSD"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": "value",
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"decimals": 2,
|
||||
"mappingType": 1,
|
||||
"pattern": "ColorValue",
|
||||
"thresholds": [
|
||||
"5",
|
||||
"10"
|
||||
],
|
||||
"type": "number",
|
||||
"unit": "Bps"
|
||||
},
|
||||
{
|
||||
"alias": "",
|
||||
"colorMode": null,
|
||||
"colors": [
|
||||
"rgba(245, 54, 54, 0.9)",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"rgba(50, 172, 45, 0.97)"
|
||||
],
|
||||
"decimals": 2,
|
||||
"pattern": "/.*/",
|
||||
"thresholds": [],
|
||||
"type": "number",
|
||||
"unit": "short"
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"alias": "ColorValue",
|
||||
"expr": "",
|
||||
"format": "table",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0,20,10"
|
||||
},
|
||||
{
|
||||
"alias": "ColorCell",
|
||||
"refId": "B",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "5,1,2,3,4,5,10,20"
|
||||
}
|
||||
],
|
||||
"title": "Column style thresholds & units",
|
||||
"transform": "timeseries_to_columns",
|
||||
"type": "table"
|
||||
}
|
||||
],
|
||||
"refresh": false,
|
||||
"revision": 8,
|
||||
"schemaVersion": 16,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "Panel Tests - Table",
|
||||
"uid": "pttable",
|
||||
"version": 1
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"revision": 2,
|
||||
"title": "TestData - Alerts",
|
||||
"title": "Alerting with TestData",
|
||||
"tags": [
|
||||
"grafana-test"
|
||||
],
|
||||
@ -48,7 +48,7 @@
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"datasource": "Grafana TestData",
|
||||
"datasource": "gdev-testdata",
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fill": 1,
|
||||
@ -161,7 +161,7 @@
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"datasource": "Grafana TestData",
|
||||
"datasource": "gdev-testdata",
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fill": 1,
|
@ -1,4 +1,4 @@
|
||||
#/bin/bash
|
||||
#!/bin/bash
|
||||
|
||||
bulkDashboard() {
|
||||
|
||||
@ -22,31 +22,37 @@ requiresJsonnet() {
|
||||
fi
|
||||
}
|
||||
|
||||
defaultDashboards() {
|
||||
devDashboards() {
|
||||
echo -e "\xE2\x9C\x94 Setting up all dev dashboards using provisioning"
|
||||
ln -s -f ../../../devenv/dashboards.yaml ../conf/provisioning/dashboards/dev.yaml
|
||||
}
|
||||
|
||||
defaultDatasources() {
|
||||
echo "setting up all default datasources using provisioning"
|
||||
devDatasources() {
|
||||
echo -e "\xE2\x9C\x94 Setting up all dev datasources using provisioning"
|
||||
|
||||
ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo -e "install.sh\n\tThis script setups dev provision for datasources and dashboards"
|
||||
echo -e "\n"
|
||||
echo "Usage:"
|
||||
echo " bulk-dashboards - create and provisioning 400 dashboards"
|
||||
echo " no args - provisiong core datasources and dev dashboards"
|
||||
}
|
||||
|
||||
main() {
|
||||
echo -e "------------------------------------------------------------------"
|
||||
echo -e "This script setups provisioning for dev datasources and dashboards"
|
||||
echo -e "------------------------------------------------------------------"
|
||||
echo -e "\n"
|
||||
|
||||
local cmd=$1
|
||||
|
||||
if [[ $cmd == "bulk-dashboards" ]]; then
|
||||
bulkDashboard
|
||||
else
|
||||
defaultDashboards
|
||||
defaultDatasources
|
||||
devDashboards
|
||||
devDatasources
|
||||
fi
|
||||
|
||||
if [[ -z "$cmd" ]]; then
|
||||
|
@ -1,3 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY htpasswd /etc/nginx/htpasswd
|
||||
|
3
docker/blocks/nginx_proxy/htpasswd
Executable file
3
docker/blocks/nginx_proxy/htpasswd
Executable file
@ -0,0 +1,3 @@
|
||||
user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0
|
||||
user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9.
|
||||
admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1
|
@ -13,7 +13,26 @@ http {
|
||||
listen 10080;
|
||||
|
||||
location /grafana/ {
|
||||
################################################################
|
||||
# Enable these settings to test with basic auth and an auth proxy header
|
||||
# the htpasswd file contains an admin user with password admin and
|
||||
# user1: grafana and user2: grafana
|
||||
################################################################
|
||||
|
||||
# auth_basic "Restricted Content";
|
||||
# auth_basic_user_file /etc/nginx/htpasswd;
|
||||
|
||||
################################################################
|
||||
# To use the auth proxy header, set the following in custom.ini:
|
||||
# [auth.proxy]
|
||||
# enabled = true
|
||||
# header_name = X-WEBAUTH-USER
|
||||
# header_property = username
|
||||
################################################################
|
||||
|
||||
# proxy_set_header X-WEBAUTH-USER $remote_user;
|
||||
|
||||
proxy_pass http://localhost:3000/;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
85
docker/blocks/openldap/ldap_dev.toml
Normal file
85
docker/blocks/openldap/ldap_dev.toml
Normal file
@ -0,0 +1,85 @@
|
||||
# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
|
||||
# [log]
|
||||
# filters = ldap:debug
|
||||
|
||||
[[servers]]
|
||||
# Ldap server host (specify multiple hosts space separated)
|
||||
host = "127.0.0.1"
|
||||
# Default port is 389 or 636 if use_ssl = true
|
||||
port = 389
|
||||
# Set to true if ldap server supports TLS
|
||||
use_ssl = false
|
||||
# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS)
|
||||
start_tls = false
|
||||
# set to true if you want to skip ssl cert validation
|
||||
ssl_skip_verify = false
|
||||
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||
# root_ca_cert = "/path/to/certificate.crt"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "cn=admin,dc=grafana,dc=org"
|
||||
# Search user bind password
|
||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||
bind_password = 'grafana'
|
||||
|
||||
# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
|
||||
search_filter = "(cn=%s)"
|
||||
|
||||
# An array of base dns to search through
|
||||
search_base_dns = ["dc=grafana,dc=org"]
|
||||
|
||||
# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups.
|
||||
# This is done by enabling group_search_filter below. You must also set member_of= "cn"
|
||||
# in [servers.attributes] below.
|
||||
|
||||
# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN
|
||||
# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of
|
||||
# below in such a way that the user's recursive group membership is considered.
|
||||
#
|
||||
# Nested Groups + Active Directory (AD) Example:
|
||||
#
|
||||
# AD groups store the Distinguished Names (DNs) of members, so your filter must
|
||||
# recursively search your groups for the authenticating user's DN. For example:
|
||||
#
|
||||
# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)"
|
||||
# group_search_filter_user_attribute = "distinguishedName"
|
||||
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||
#
|
||||
# [servers.attributes]
|
||||
# ...
|
||||
# member_of = "distinguishedName"
|
||||
|
||||
## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available)
|
||||
# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
|
||||
## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter.
|
||||
## Defaults to the value of username in [server.attributes]
|
||||
## Valid options are any of your values in [servers.attributes]
|
||||
## If you are using nested groups you probably want to set this and member_of in
|
||||
## [servers.attributes] to "distinguishedName"
|
||||
# group_search_filter_user_attribute = "distinguishedName"
|
||||
## An array of the base DNs to search through for groups. Typically uses ou=groups
|
||||
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||
|
||||
# Specify names of the ldap attributes your ldap uses
|
||||
[servers.attributes]
|
||||
name = "givenName"
|
||||
surname = "sn"
|
||||
username = "cn"
|
||||
member_of = "memberOf"
|
||||
email = "email"
|
||||
|
||||
# Map ldap groups to grafana org roles
|
||||
[[servers.group_mappings]]
|
||||
group_dn = "cn=admins,ou=groups,dc=grafana,dc=org"
|
||||
org_role = "Admin"
|
||||
# The Grafana organization database id, optional, if left out the default org (id 1) will be used
|
||||
# org_id = 1
|
||||
|
||||
[[servers.group_mappings]]
|
||||
group_dn = "cn=editors,ou=groups,dc=grafana,dc=org"
|
||||
org_role = "Editor"
|
||||
|
||||
[[servers.group_mappings]]
|
||||
# If you want to match all (or no ldap groups) then you can use wildcard
|
||||
group_dn = "*"
|
||||
org_role = "Viewer"
|
@ -14,12 +14,12 @@ After adding ldif files to `prepopulate`:
|
||||
|
||||
## Enabling LDAP in Grafana
|
||||
|
||||
The default `ldap.toml` file in `conf` has host set to `127.0.0.1` and port to set to 389 so all you need to do is enable it in the .ini file to get Grafana to use this block:
|
||||
Copy the ldap_dev.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block:
|
||||
|
||||
```ini
|
||||
[auth.ldap]
|
||||
enabled = true
|
||||
config_file = conf/ldap.toml
|
||||
config_file = conf/ldap_dev.toml
|
||||
; allow_sign_up = true
|
||||
```
|
||||
|
||||
@ -43,6 +43,3 @@ editors
|
||||
|
||||
no groups
|
||||
ldap-viewer
|
||||
|
||||
|
||||
|
||||
|
@ -75,6 +75,32 @@ Name | Description
|
||||
|
||||
For details of *metric names*, *label names* and *label values* are please refer to the [Prometheus documentation](http://prometheus.io/docs/concepts/data_model/#metric-names-and-labels).
|
||||
|
||||
|
||||
#### Using interval and range variables
|
||||
|
||||
> Support for `$__range` and `$__range_ms` only available from Grafana v5.3
|
||||
|
||||
It's possible to use some global built-in variables in query variables; `$__interval`, `$__interval_ms`, `$__range` and `$__range_ms`, see [Global built-in variables](/reference/templating/#global-built-in-variables) for more information. These can be convenient to use in conjunction with the `query_result` function when you need to filter variable queries since
|
||||
`label_values` function doesn't support queries.
|
||||
|
||||
Make sure to set the variable's `refresh` trigger to be `On Time Range Change` to get the correct instances when changing the time range on the dashboard.
|
||||
|
||||
**Example usage:**
|
||||
|
||||
Populate a variable with the the busiest 5 request instances based on average QPS over the time range shown in the dashboard:
|
||||
|
||||
```
|
||||
Query: query_result(topk(5, sum(rate(http_requests_total[$__range])) by (instance)))
|
||||
Regex: /"([^"]+)"/
|
||||
```
|
||||
|
||||
Populate a variable with the instances having a certain state over the time range shown in the dashboard:
|
||||
|
||||
```
|
||||
Query: query_result(max_over_time(<metric>[$__range]) != <state>)
|
||||
Regex:
|
||||
```
|
||||
|
||||
### Using variables in queries
|
||||
|
||||
There are two syntaxes:
|
||||
|
286
docs/sources/http_api/playlist.md
Normal file
286
docs/sources/http_api/playlist.md
Normal file
@ -0,0 +1,286 @@
|
||||
+++
|
||||
title = "Playlist HTTP API "
|
||||
description = "Playlist Admin HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "playlist"]
|
||||
aliases = ["/http_api/playlist/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Playlist"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Playlist API
|
||||
|
||||
## Search Playlist
|
||||
|
||||
`GET /api/playlists`
|
||||
|
||||
Get all existing playlist for the current organization using pagination
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
GET /api/playlists HTTP/1.1
|
||||
Accept: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
Querystring Parameters:
|
||||
|
||||
These parameters are used as querystring parameters.
|
||||
|
||||
- **query** - Limit response to playlist having a name like this value.
|
||||
- **limit** - Limit response to *X* number of playlist.
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "my playlist",
|
||||
"interval": "5m"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Get one playlist
|
||||
|
||||
`GET /api/playlists/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
GET /api/playlists/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
{
|
||||
"id" : 1,
|
||||
"name": "my playlist",
|
||||
"interval": "5m",
|
||||
"orgId": "my org",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_id",
|
||||
"value": "3",
|
||||
"order": 1,
|
||||
"title":"my third dasboard"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_tag",
|
||||
"value": "myTag",
|
||||
"order": 2,
|
||||
"title":"my other dasboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Get Playlist items
|
||||
|
||||
`GET /api/playlists/:id/items`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
GET /api/playlists/1/items HTTP/1.1
|
||||
Accept: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_id",
|
||||
"value": "3",
|
||||
"order": 1,
|
||||
"title":"my third dasboard"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_tag",
|
||||
"value": "myTag",
|
||||
"order": 2,
|
||||
"title":"my other dasboard"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Get Playlist dashboards
|
||||
|
||||
`GET /api/playlists/:id/dashboards`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
GET /api/playlists/1/dashboards HTTP/1.1
|
||||
Accept: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
[
|
||||
{
|
||||
"id": 3,
|
||||
"title": "my third dasboard",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title":"my other dasboard"
|
||||
"order": 2,
|
||||
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Create a playlist
|
||||
|
||||
`POST /api/playlists/`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
PUT /api/playlists/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
{
|
||||
"name": "my playlist",
|
||||
"interval": "5m",
|
||||
"items": [
|
||||
{
|
||||
"type": "dashboard_by_id",
|
||||
"value": "3",
|
||||
"order": 1,
|
||||
"title":"my third dasboard"
|
||||
},
|
||||
{
|
||||
"type": "dashboard_by_tag",
|
||||
"value": "myTag",
|
||||
"order": 2,
|
||||
"title":"my other dasboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "my playlist",
|
||||
"interval": "5m"
|
||||
}
|
||||
```
|
||||
|
||||
## Update a playlist
|
||||
|
||||
`PUT /api/playlists/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
PUT /api/playlists/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
{
|
||||
"name": "my playlist",
|
||||
"interval": "5m",
|
||||
"items": [
|
||||
{
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_id",
|
||||
"value": "3",
|
||||
"order": 1,
|
||||
"title":"my third dasboard"
|
||||
},
|
||||
{
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_tag",
|
||||
"value": "myTag",
|
||||
"order": 2,
|
||||
"title":"my other dasboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
{
|
||||
"id" : 1,
|
||||
"name": "my playlist",
|
||||
"interval": "5m",
|
||||
"orgId": "my org",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_id",
|
||||
"value": "3",
|
||||
"order": 1,
|
||||
"title":"my third dasboard"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"playlistId": 1,
|
||||
"type": "dashboard_by_tag",
|
||||
"value": "myTag",
|
||||
"order": 2,
|
||||
"title":"my other dasboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Delete a playlist
|
||||
|
||||
`DELETE /api/playlists/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```bash
|
||||
DELETE /api/playlists/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
{}
|
||||
```
|
@ -15,6 +15,8 @@ weight = 1
|
||||
The Grafana back-end has a number of configuration options that can be
|
||||
specified in a `.ini` configuration file or specified using environment variables.
|
||||
|
||||
> **Note.** Grafana needs to be restarted for any configuration changes to take effect.
|
||||
|
||||
## Comments In .ini Files
|
||||
|
||||
Semicolons (the `;` char) are the standard way to comment out lines in a `.ini` file.
|
||||
@ -296,6 +298,12 @@ Set to `true` to automatically add new users to the main organization
|
||||
(id 1). When set to `false`, new users will automatically cause a new
|
||||
organization to be created for that new user.
|
||||
|
||||
### auto_assign_org_id
|
||||
|
||||
Set this value to automatically add new users to the provided org.
|
||||
This requires `auto_assign_org` to be set to `true`. Please make sure
|
||||
that this organization does already exists.
|
||||
|
||||
### auto_assign_org_role
|
||||
|
||||
The role new users will be assigned for the main organization (if the
|
||||
@ -857,7 +865,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
Url to where Grafana will send PUT request with images
|
||||
|
||||
### public_url
|
||||
Optional parameter. Url to send to users in notifications, directly appended with the resulting uploaded file name.
|
||||
Optional parameter. Url to send to users in notifications. If the string contains the sequence ${file}, it will be replaced with the uploaded filename. Otherwise, the file name will be appended to the path part of the url, leaving any query string unchanged.
|
||||
|
||||
### username
|
||||
basic auth username
|
||||
|
@ -23,8 +23,9 @@ specific configuration file (default: `/etc/grafana/ldap.toml`).
|
||||
### Example config
|
||||
|
||||
```toml
|
||||
# Set to true to log user information returned from LDAP
|
||||
verbose_logging = false
|
||||
# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
|
||||
# [log]
|
||||
# filters = ldap:debug
|
||||
|
||||
[[servers]]
|
||||
# Ldap server host (specify multiple hosts space separated)
|
||||
@ -73,6 +74,8 @@ email = "email"
|
||||
[[servers.group_mappings]]
|
||||
group_dn = "cn=admins,dc=grafana,dc=org"
|
||||
org_role = "Admin"
|
||||
# To make user an instance admin (Grafana Admin) uncomment line below
|
||||
# grafana_admin = true
|
||||
# The Grafana organization database id, optional, if left out the default org (id 1) will be used. Setting this allows for multiple group_dn's to be assigned to the same org_role provided the org_id differs
|
||||
# org_id = 1
|
||||
|
||||
@ -132,6 +135,10 @@ Users page, this change will be reset the next time the user logs in. If you
|
||||
change the LDAP groups of a user, the change will take effect the next
|
||||
time the user logs in.
|
||||
|
||||
### Grafana Admin
|
||||
with a servers.group_mappings section you can set grafana_admin = true or false to sync Grafana Admin permission. A Grafana server admin has admin access over all orgs &
|
||||
users.
|
||||
|
||||
### Priority
|
||||
The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the TOML config will be used.
|
||||
|
||||
|
@ -11,7 +11,7 @@ weight = 1
|
||||
# Variables
|
||||
|
||||
Variables allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application
|
||||
and sensor name in you metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
|
||||
and sensor name in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
|
||||
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}}
|
||||
@ -273,6 +273,12 @@ The `$__timeFilter` is used in the MySQL data source.
|
||||
|
||||
This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias.
|
||||
|
||||
### The $__range Variable
|
||||
|
||||
> Only available in Grafana v5.3+
|
||||
|
||||
Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond representation called `$__range_ms`.
|
||||
|
||||
## Repeating Panels
|
||||
|
||||
Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want
|
||||
|
16
package.json
16
package.json
@ -34,7 +34,7 @@
|
||||
"expose-loader": "^0.7.3",
|
||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||
"file-loader": "^1.1.11",
|
||||
"fork-ts-checker-webpack-plugin": "^0.4.1",
|
||||
"fork-ts-checker-webpack-plugin": "^0.4.2",
|
||||
"gaze": "^1.1.2",
|
||||
"glob": "~7.0.0",
|
||||
"grunt": "1.0.1",
|
||||
@ -71,12 +71,14 @@
|
||||
"karma-webpack": "^3.0.0",
|
||||
"lint-staged": "^6.0.0",
|
||||
"load-grunt-tasks": "3.5.2",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"mobx-react-devtools": "^4.2.15",
|
||||
"mocha": "^4.0.1",
|
||||
"ng-annotate-loader": "^0.6.1",
|
||||
"ng-annotate-webpack-plugin": "^0.2.1-pre",
|
||||
"ngtemplate-loader": "^2.0.1",
|
||||
"npm": "^5.4.2",
|
||||
"optimize-css-assets-webpack-plugin": "^4.0.2",
|
||||
"phantomjs-prebuilt": "^2.1.15",
|
||||
"postcss-browser-reporter": "^0.5.0",
|
||||
"postcss-loader": "^2.0.6",
|
||||
@ -90,15 +92,16 @@
|
||||
"style-loader": "^0.21.0",
|
||||
"systemjs": "0.20.19",
|
||||
"systemjs-plugin-css": "^0.1.36",
|
||||
"ts-loader": "^4.3.0",
|
||||
"ts-jest": "^22.4.6",
|
||||
"ts-loader": "^4.3.0",
|
||||
"tslib": "^1.9.3",
|
||||
"tslint": "^5.8.0",
|
||||
"tslint-loader": "^3.5.3",
|
||||
"typescript": "^2.6.2",
|
||||
"uglifyjs-webpack-plugin": "^1.2.7",
|
||||
"webpack": "^4.8.0",
|
||||
"webpack-bundle-analyzer": "^2.9.0",
|
||||
"webpack-cleanup-plugin": "^0.5.1",
|
||||
"fork-ts-checker-webpack-plugin": "^0.4.2",
|
||||
"webpack-cli": "^2.1.4",
|
||||
"webpack-dev-server": "^3.1.0",
|
||||
"webpack-merge": "^4.1.0",
|
||||
@ -155,14 +158,12 @@
|
||||
"immutable": "^3.8.2",
|
||||
"jquery": "^3.2.1",
|
||||
"lodash": "^4.17.10",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"mobx": "^3.4.1",
|
||||
"mobx-react": "^4.3.5",
|
||||
"mobx-state-tree": "^1.3.1",
|
||||
"moment": "^2.22.2",
|
||||
"mousetrap": "^1.6.0",
|
||||
"mousetrap-global-bind": "^1.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^4.0.2",
|
||||
"prismjs": "^1.6.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react": "^16.2.0",
|
||||
@ -181,10 +182,9 @@
|
||||
"slate-react": "^0.12.4",
|
||||
"tether": "^1.4.0",
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "^1.4.1",
|
||||
"uglifyjs-webpack-plugin": "^1.2.7"
|
||||
"tinycolor2": "^1.4.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772"
|
||||
}
|
||||
}
|
||||
}
|
@ -31,7 +31,7 @@ func TestAlertingApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = []*m.Team{}
|
||||
query.Result = []*m.TeamDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
@ -119,7 +119,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = []*m.Team{}
|
||||
query.Result = []*m.TeamDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/dashboards/", reqSignedIn, Index)
|
||||
r.Get("/dashboards/*", reqSignedIn, Index)
|
||||
|
||||
r.Get("/explore/", reqEditorRole, Index)
|
||||
r.Get("/explore/*", reqEditorRole, Index)
|
||||
r.Get("/explore", reqEditorRole, Index)
|
||||
|
||||
r.Get("/playlists/", reqSignedIn, Index)
|
||||
r.Get("/playlists/*", reqSignedIn, Index)
|
||||
|
@ -39,7 +39,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
teamResp := []*m.Team{}
|
||||
teamResp := []*m.TeamDTO{}
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = teamResp
|
||||
return nil
|
||||
|
@ -61,7 +61,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = []*m.Team{}
|
||||
query.Result = []*m.TeamDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
@ -230,7 +230,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = []*m.Team{}
|
||||
query.Result = []*m.TeamDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
@ -52,7 +52,7 @@ func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response {
|
||||
if res.Error != nil {
|
||||
res.ErrorString = res.Error.Error()
|
||||
resp.Message = res.ErrorString
|
||||
statusCode = 500
|
||||
statusCode = 400
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response {
|
||||
|
||||
func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response {
|
||||
cmd.OrgId = c.OrgId
|
||||
cmd.Id = c.ParamsInt64(":id")
|
||||
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
return Error(500, "Failed to save playlist", err)
|
||||
|
@ -93,5 +93,6 @@ func GetTeamByID(c *m.ReqContext) Response {
|
||||
return Error(500, "Failed to get Team", err)
|
||||
}
|
||||
|
||||
query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
|
||||
return JSON(200, &query.Result)
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import (
|
||||
func TestTeamApiEndpoint(t *testing.T) {
|
||||
Convey("Given two teams", t, func() {
|
||||
mockResult := models.SearchTeamQueryResult{
|
||||
Teams: []*models.SearchTeamDto{
|
||||
Teams: []*models.TeamDTO{
|
||||
{Name: "team1"},
|
||||
{Name: "team2"},
|
||||
},
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@ -35,6 +36,16 @@ var netClient = &http.Client{
|
||||
Transport: netTransport,
|
||||
}
|
||||
|
||||
func (u *WebdavUploader) PublicURL(filename string) string {
|
||||
if strings.Contains(u.public_url, "${file}") {
|
||||
return strings.Replace(u.public_url, "${file}", filename, -1)
|
||||
} else {
|
||||
publicURL, _ := url.Parse(u.public_url)
|
||||
publicURL.Path = path.Join(publicURL.Path, filename)
|
||||
return publicURL.String()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
|
||||
url, _ := url.Parse(u.url)
|
||||
filename := util.GetRandomString(20) + ".png"
|
||||
@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
|
||||
}
|
||||
|
||||
if u.public_url != "" {
|
||||
publicURL, _ := url.Parse(u.public_url)
|
||||
publicURL.Path = path.Join(publicURL.Path, filename)
|
||||
return publicURL.String(), nil
|
||||
return u.PublicURL(filename), nil
|
||||
}
|
||||
|
||||
return url.String(), nil
|
||||
|
@ -2,6 +2,7 @@ package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) {
|
||||
So(path, ShouldStartWith, "http://publicurl:8888/webdav/")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublicURL(t *testing.T) {
|
||||
Convey("Given a public URL with parameters, and no template", t, func() {
|
||||
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=")
|
||||
parsed, _ := url.Parse(webdavUploader.PublicURL("fileyfile.png"))
|
||||
So(parsed.Path, ShouldEndWith, "fileyfile.png")
|
||||
})
|
||||
Convey("Given a public URL with parameters, and a template", t, func() {
|
||||
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=${file}")
|
||||
So(webdavUploader.PublicURL("fileyfile.png"), ShouldEndWith, "fileyfile.png")
|
||||
})
|
||||
}
|
||||
|
@ -72,6 +72,13 @@ func UpsertUser(cmd *m.UpsertUserCommand) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync isGrafanaAdmin permission
|
||||
if extUser.IsGrafanaAdmin != nil && *extUser.IsGrafanaAdmin != cmd.Result.IsAdmin {
|
||||
if err := bus.Dispatch(&m.UpdateUserPermissionsCommand{UserId: cmd.Result.Id, IsGrafanaAdmin: *extUser.IsGrafanaAdmin}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = bus.Dispatch(&m.SyncTeamsCommand{
|
||||
User: cmd.Result,
|
||||
ExternalUser: extUser,
|
||||
|
@ -175,6 +175,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
|
||||
|
||||
if ldapUser.isMemberOf(group.GroupDN) {
|
||||
extUser.OrgRoles[group.OrgId] = group.OrgRole
|
||||
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,18 +191,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
|
||||
}
|
||||
|
||||
// add/update user in grafana
|
||||
userQuery := &m.UpsertUserCommand{
|
||||
upsertUserCmd := &m.UpsertUserCommand{
|
||||
ReqContext: ctx,
|
||||
ExternalUser: extUser,
|
||||
SignupAllowed: setting.LdapAllowSignup,
|
||||
}
|
||||
|
||||
err := bus.Dispatch(userQuery)
|
||||
err := bus.Dispatch(upsertUserCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userQuery.Result, nil
|
||||
return upsertUserCmd.Result, nil
|
||||
}
|
||||
|
||||
func (a *ldapAuther) serverBind() error {
|
||||
|
@ -44,9 +44,10 @@ type LdapAttributeMap struct {
|
||||
}
|
||||
|
||||
type LdapGroupToOrgRole struct {
|
||||
GroupDN string `toml:"group_dn"`
|
||||
OrgId int64 `toml:"org_id"`
|
||||
OrgRole m.RoleType `toml:"org_role"`
|
||||
GroupDN string `toml:"group_dn"`
|
||||
OrgId int64 `toml:"org_id"`
|
||||
IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatability)
|
||||
OrgRole m.RoleType `toml:"org_role"`
|
||||
}
|
||||
|
||||
var LdapCfg LdapConfig
|
||||
|
@ -98,6 +98,10 @@ func TestLdapAuther(t *testing.T) {
|
||||
So(result.Login, ShouldEqual, "torkelo")
|
||||
})
|
||||
|
||||
Convey("Should set isGrafanaAdmin to false by default", func() {
|
||||
So(result.IsAdmin, ShouldBeFalse)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
@ -223,8 +227,32 @@ func TestLdapAuther(t *testing.T) {
|
||||
So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
|
||||
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
|
||||
})
|
||||
|
||||
Convey("Should not update permissions unless specified", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(sc.updateUserPermissionsCmd, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
ldapAutherScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) {
|
||||
trueVal := true
|
||||
|
||||
ldapAuther := NewLdapAuthenticator(&LdapServerConf{
|
||||
LdapGroups: []*LdapGroupToOrgRole{
|
||||
{GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin", IsGrafanaAdmin: &trueVal},
|
||||
},
|
||||
})
|
||||
|
||||
sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
|
||||
_, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{
|
||||
MemberOf: []string{"cn=admins"},
|
||||
})
|
||||
|
||||
Convey("Should create user with admin set to true", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(sc.updateUserPermissionsCmd.IsGrafanaAdmin, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When calling SyncUser", t, func() {
|
||||
@ -332,6 +360,11 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpdateUserPermissionsCommand) error {
|
||||
sc.updateUserPermissionsCmd = cmd
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error {
|
||||
sc.getUserByAuthInfoQuery = cmd
|
||||
sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login}
|
||||
@ -379,14 +412,15 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery
|
||||
getUserOrgListQuery *m.GetUserOrgListQuery
|
||||
createUserCmd *m.CreateUserCommand
|
||||
addOrgUserCmd *m.AddOrgUserCommand
|
||||
updateOrgUserCmd *m.UpdateOrgUserCommand
|
||||
removeOrgUserCmd *m.RemoveOrgUserCommand
|
||||
updateUserCmd *m.UpdateUserCommand
|
||||
setUsingOrgCmd *m.SetUsingOrgCommand
|
||||
getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery
|
||||
getUserOrgListQuery *m.GetUserOrgListQuery
|
||||
createUserCmd *m.CreateUserCommand
|
||||
addOrgUserCmd *m.AddOrgUserCommand
|
||||
updateOrgUserCmd *m.UpdateOrgUserCommand
|
||||
removeOrgUserCmd *m.RemoveOrgUserCommand
|
||||
updateUserCmd *m.UpdateUserCommand
|
||||
setUsingOrgCmd *m.SetUsingOrgCommand
|
||||
updateUserPermissionsCmd *m.UpdateUserPermissionsCommand
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) userQueryReturns(user *m.User) {
|
||||
|
@ -44,6 +44,7 @@ var (
|
||||
M_Alerting_Notification_Sent *prometheus.CounterVec
|
||||
M_Aws_CloudWatch_GetMetricStatistics prometheus.Counter
|
||||
M_Aws_CloudWatch_ListMetrics prometheus.Counter
|
||||
M_Aws_CloudWatch_GetMetricData prometheus.Counter
|
||||
M_DB_DataSource_QueryById prometheus.Counter
|
||||
|
||||
// Timers
|
||||
@ -218,6 +219,12 @@ func init() {
|
||||
Namespace: exporterName,
|
||||
})
|
||||
|
||||
M_Aws_CloudWatch_GetMetricData = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "aws_cloudwatch_get_metric_data_total",
|
||||
Help: "counter for getting metric data time series from aws",
|
||||
Namespace: exporterName,
|
||||
})
|
||||
|
||||
M_DB_DataSource_QueryById = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "db_datasource_query_by_id_total",
|
||||
Help: "counter for getting datasource by id",
|
||||
@ -307,6 +314,7 @@ func initMetricVars() {
|
||||
M_Alerting_Notification_Sent,
|
||||
M_Aws_CloudWatch_GetMetricStatistics,
|
||||
M_Aws_CloudWatch_ListMetrics,
|
||||
M_Aws_CloudWatch_GetMetricData,
|
||||
M_DB_DataSource_QueryById,
|
||||
M_Alerting_Active_Alerts,
|
||||
M_StatTotal_Dashboards,
|
||||
|
@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard
|
||||
|
||||
type UpdatePlaylistCommand struct {
|
||||
OrgId int64 `json:"-"`
|
||||
Id int64 `json:"id" binding:"Required"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Interval string `json:"interval"`
|
||||
Items []PlaylistItemDTO `json:"items"`
|
||||
|
@ -49,13 +49,13 @@ type DeleteTeamCommand struct {
|
||||
type GetTeamByIdQuery struct {
|
||||
OrgId int64
|
||||
Id int64
|
||||
Result *Team
|
||||
Result *TeamDTO
|
||||
}
|
||||
|
||||
type GetTeamsByUserQuery struct {
|
||||
OrgId int64
|
||||
UserId int64 `json:"userId"`
|
||||
Result []*Team `json:"teams"`
|
||||
UserId int64 `json:"userId"`
|
||||
Result []*TeamDTO `json:"teams"`
|
||||
}
|
||||
|
||||
type SearchTeamsQuery struct {
|
||||
@ -68,7 +68,7 @@ type SearchTeamsQuery struct {
|
||||
Result SearchTeamQueryResult
|
||||
}
|
||||
|
||||
type SearchTeamDto struct {
|
||||
type TeamDTO struct {
|
||||
Id int64 `json:"id"`
|
||||
OrgId int64 `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
@ -78,8 +78,8 @@ type SearchTeamDto struct {
|
||||
}
|
||||
|
||||
type SearchTeamQueryResult struct {
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Teams []*SearchTeamDto `json:"teams"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Teams []*TeamDTO `json:"teams"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
@ -13,14 +13,15 @@ type UserAuth struct {
|
||||
}
|
||||
|
||||
type ExternalUserInfo struct {
|
||||
AuthModule string
|
||||
AuthId string
|
||||
UserId int64
|
||||
Email string
|
||||
Login string
|
||||
Name string
|
||||
Groups []string
|
||||
OrgRoles map[int64]RoleType
|
||||
AuthModule string
|
||||
AuthId string
|
||||
UserId int64
|
||||
Email string
|
||||
Login string
|
||||
Name string
|
||||
Groups []string
|
||||
OrgRoles map[int64]RoleType
|
||||
IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync)
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
|
@ -17,11 +17,14 @@ import (
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
// DataSourcePlugin contains all metadata about a datasource plugin
|
||||
type DataSourcePlugin struct {
|
||||
FrontendPluginBase
|
||||
Annotations bool `json:"annotations"`
|
||||
Metrics bool `json:"metrics"`
|
||||
Alerting bool `json:"alerting"`
|
||||
Explore bool `json:"explore"`
|
||||
Logs bool `json:"logs"`
|
||||
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
|
||||
BuiltIn bool `json:"builtIn,omitempty"`
|
||||
Mixed bool `json:"mixed,omitempty"`
|
||||
|
@ -30,7 +30,7 @@ type dashboardGuardianImpl struct {
|
||||
dashId int64
|
||||
orgId int64
|
||||
acl []*m.DashboardAclInfoDTO
|
||||
groups []*m.Team
|
||||
teams []*m.TeamDTO
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
@ -186,15 +186,15 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
|
||||
return g.acl, nil
|
||||
}
|
||||
|
||||
func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) {
|
||||
if g.groups != nil {
|
||||
return g.groups, nil
|
||||
func (g *dashboardGuardianImpl) getTeams() ([]*m.TeamDTO, error) {
|
||||
if g.teams != nil {
|
||||
return g.teams, nil
|
||||
}
|
||||
|
||||
query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
|
||||
err := bus.Dispatch(&query)
|
||||
|
||||
g.groups = query.Result
|
||||
g.teams = query.Result
|
||||
return query.Result, err
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ type scenarioContext struct {
|
||||
givenUser *m.SignedInUser
|
||||
givenDashboardID int64
|
||||
givenPermissions []*m.DashboardAclInfoDTO
|
||||
givenTeams []*m.Team
|
||||
givenTeams []*m.TeamDTO
|
||||
updatePermissions []*m.DashboardAcl
|
||||
expectedFlags permissionFlags
|
||||
callerFile string
|
||||
@ -84,11 +84,11 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, per
|
||||
return nil
|
||||
})
|
||||
|
||||
teams := []*m.Team{}
|
||||
teams := []*m.TeamDTO{}
|
||||
|
||||
for _, p := range permissions {
|
||||
if p.TeamId > 0 {
|
||||
teams = append(teams, &m.Team{Id: p.TeamId})
|
||||
teams = append(teams, &m.TeamDTO{Id: p.TeamId})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
|
||||
alert.name,
|
||||
alert.state,
|
||||
alert.new_state_date,
|
||||
alert.eval_data,
|
||||
alert.eval_date,
|
||||
alert.execution_error,
|
||||
dashboard.uid as dashboard_uid,
|
||||
|
@ -13,7 +13,7 @@ func mockTimeNow() {
|
||||
var timeSeed int64
|
||||
timeNow = func() time.Time {
|
||||
fakeNow := time.Unix(timeSeed, 0)
|
||||
timeSeed += 1
|
||||
timeSeed++
|
||||
return fakeNow
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
InitTestDB(t)
|
||||
|
||||
testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
|
||||
|
||||
evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`))
|
||||
items := []*m.Alert{
|
||||
{
|
||||
PanelId: 1,
|
||||
@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
Message: "Alerting message",
|
||||
Settings: simplejson.New(),
|
||||
Frequency: 1,
|
||||
EvalData: evalData,
|
||||
},
|
||||
}
|
||||
|
||||
@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
|
||||
alert := alertQuery.Result[0]
|
||||
So(err2, ShouldBeNil)
|
||||
So(alert.Id, ShouldBeGreaterThan, 0)
|
||||
So(alert.DashboardId, ShouldEqual, testDash.Id)
|
||||
So(alert.PanelId, ShouldEqual, 1)
|
||||
So(alert.Name, ShouldEqual, "Alerting title")
|
||||
So(alert.State, ShouldEqual, "pending")
|
||||
So(alert.NewStateDate, ShouldNotBeNil)
|
||||
So(alert.EvalData, ShouldNotBeNil)
|
||||
So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
|
||||
So(alert.EvalDate, ShouldNotBeNil)
|
||||
So(alert.ExecutionError, ShouldEqual, "")
|
||||
So(alert.DashboardUid, ShouldNotBeNil)
|
||||
So(alert.DashboardSlug, ShouldEqual, "dashboard-with-alerts")
|
||||
})
|
||||
|
||||
Convey("Viewer cannot read alerts", func() {
|
||||
|
@ -181,7 +181,7 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result.FolderId, ShouldEqual, 0)
|
||||
So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy)
|
||||
So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second))
|
||||
So(query.Result.Created, ShouldHappenWithin, 3*time.Second, savedDash.Created)
|
||||
So(query.Result.UpdatedBy, ShouldEqual, 100)
|
||||
So(query.Result.Updated.IsZero(), ShouldBeFalse)
|
||||
})
|
||||
@ -387,6 +387,7 @@ func insertTestDashboardForPlugin(title string, orgId int64, folderId int64, isF
|
||||
|
||||
func createUser(name string, role string, isAdmin bool) m.User {
|
||||
setting.AutoAssignOrg = true
|
||||
setting.AutoAssignOrgId = 1
|
||||
setting.AutoAssignOrgRole = role
|
||||
|
||||
currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}
|
||||
|
@ -17,6 +17,7 @@ func TestAccountDataAccess(t *testing.T) {
|
||||
|
||||
Convey("Given single org mode", func() {
|
||||
setting.AutoAssignOrg = true
|
||||
setting.AutoAssignOrgId = 1
|
||||
setting.AutoAssignOrgRole = "Viewer"
|
||||
|
||||
Convey("Users should be added to default organization", func() {
|
||||
|
@ -22,6 +22,16 @@ func init() {
|
||||
bus.AddHandler("sql", GetTeamMembers)
|
||||
}
|
||||
|
||||
func getTeamSelectSqlBase() string {
|
||||
return `SELECT
|
||||
team.id as id,
|
||||
team.org_id,
|
||||
team.name as name,
|
||||
team.email as email,
|
||||
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
|
||||
FROM team as team `
|
||||
}
|
||||
|
||||
func CreateTeam(cmd *m.CreateTeamCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
|
||||
@ -130,21 +140,15 @@ func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession
|
||||
|
||||
func SearchTeams(query *m.SearchTeamsQuery) error {
|
||||
query.Result = m.SearchTeamQueryResult{
|
||||
Teams: make([]*m.SearchTeamDto, 0),
|
||||
Teams: make([]*m.TeamDTO, 0),
|
||||
}
|
||||
queryWithWildcards := "%" + query.Query + "%"
|
||||
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
sql.WriteString(`select
|
||||
team.id as id,
|
||||
team.org_id,
|
||||
team.name as name,
|
||||
team.email as email,
|
||||
(select count(*) from team_member where team_member.team_id = team.id) as member_count
|
||||
from team as team
|
||||
where team.org_id = ?`)
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
sql.WriteString(` WHERE team.org_id = ?`)
|
||||
|
||||
params = append(params, query.OrgId)
|
||||
|
||||
@ -186,8 +190,14 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
|
||||
}
|
||||
|
||||
func GetTeamById(query *m.GetTeamByIdQuery) error {
|
||||
var team m.Team
|
||||
exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team)
|
||||
var sql bytes.Buffer
|
||||
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
sql.WriteString(` WHERE team.org_id = ? and team.id = ?`)
|
||||
|
||||
var team m.TeamDTO
|
||||
exists, err := x.Sql(sql.String(), query.OrgId, query.Id).Get(&team)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -202,13 +212,15 @@ func GetTeamById(query *m.GetTeamByIdQuery) error {
|
||||
|
||||
// GetTeamsByUser is used by the Guardian when checking a users' permissions
|
||||
func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = make([]*m.Team, 0)
|
||||
query.Result = make([]*m.TeamDTO, 0)
|
||||
|
||||
sess := x.Table("team")
|
||||
sess.Join("INNER", "team_member", "team.id=team_member.team_id")
|
||||
sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId)
|
||||
var sql bytes.Buffer
|
||||
|
||||
err := sess.Find(&query.Result)
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`)
|
||||
sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`)
|
||||
|
||||
err := x.Sql(sql.String(), query.OrgId, query.UserId).Find(&query.Result)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -42,16 +42,23 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error
|
||||
var org m.Org
|
||||
|
||||
if setting.AutoAssignOrg {
|
||||
// right now auto assign to org with id 1
|
||||
has, err := sess.Where("id=?", 1).Get(&org)
|
||||
has, err := sess.Where("id=?", setting.AutoAssignOrgId).Get(&org)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if has {
|
||||
return org.Id, nil
|
||||
} else {
|
||||
if setting.AutoAssignOrgId == 1 {
|
||||
org.Name = "Main Org."
|
||||
org.Id = int64(setting.AutoAssignOrgId)
|
||||
} else {
|
||||
sqlog.Info("Could not create user: organization id %v does not exist",
|
||||
setting.AutoAssignOrgId)
|
||||
return 0, fmt.Errorf("Could not create user: organization id %v does not exist",
|
||||
setting.AutoAssignOrgId)
|
||||
}
|
||||
}
|
||||
org.Name = "Main Org."
|
||||
org.Id = 1
|
||||
} else {
|
||||
org.Name = cmd.OrgName
|
||||
if len(org.Name) == 0 {
|
||||
|
@ -100,6 +100,7 @@ var (
|
||||
AllowUserSignUp bool
|
||||
AllowUserOrgCreate bool
|
||||
AutoAssignOrg bool
|
||||
AutoAssignOrgId int
|
||||
AutoAssignOrgRole string
|
||||
VerifyEmailEnabled bool
|
||||
LoginHint string
|
||||
@ -592,6 +593,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
AllowUserSignUp = users.Key("allow_sign_up").MustBool(true)
|
||||
AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true)
|
||||
AutoAssignOrg = users.Key("auto_assign_org").MustBool(true)
|
||||
AutoAssignOrgId = users.Key("auto_assign_org_id").MustInt(1)
|
||||
AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
|
||||
VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
|
||||
LoginHint = users.Key("login_hint").String()
|
||||
|
@ -14,8 +14,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/request"
|
||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||
@ -88,48 +90,80 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
|
||||
Results: make(map[string]*tsdb.QueryResult),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
resCh := make(chan *tsdb.QueryResult, 1)
|
||||
eg, ectx := errgroup.WithContext(ctx)
|
||||
|
||||
currentlyExecuting := 0
|
||||
getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery)
|
||||
for i, model := range queryContext.Queries {
|
||||
queryType := model.Model.Get("type").MustString()
|
||||
if queryType != "timeSeriesQuery" && queryType != "" {
|
||||
continue
|
||||
}
|
||||
currentlyExecuting++
|
||||
go func(refId string, index int) {
|
||||
queryRes, err := e.executeQuery(ctx, queryContext.Queries[index].Model, queryContext)
|
||||
currentlyExecuting--
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
} else {
|
||||
queryRes.RefId = refId
|
||||
resCh <- queryRes
|
||||
|
||||
RefId := queryContext.Queries[i].RefId
|
||||
query, err := parseQuery(queryContext.Queries[i].Model)
|
||||
if err != nil {
|
||||
result.Results[RefId] = &tsdb.QueryResult{
|
||||
Error: err,
|
||||
}
|
||||
}(model.RefId, i)
|
||||
return result, nil
|
||||
}
|
||||
query.RefId = RefId
|
||||
|
||||
if query.Id != "" {
|
||||
if _, ok := getMetricDataQueries[query.Region]; !ok {
|
||||
getMetricDataQueries[query.Region] = make(map[string]*CloudWatchQuery)
|
||||
}
|
||||
getMetricDataQueries[query.Region][query.Id] = query
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Id == "" && query.Expression != "" {
|
||||
result.Results[query.RefId] = &tsdb.QueryResult{
|
||||
Error: fmt.Errorf("Invalid query: id should be set if using expression"),
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
queryRes, err := e.executeQuery(ectx, query, queryContext)
|
||||
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
|
||||
return err
|
||||
}
|
||||
result.Results[queryRes.RefId] = queryRes
|
||||
if err != nil {
|
||||
result.Results[queryRes.RefId].Error = err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
for currentlyExecuting != 0 {
|
||||
select {
|
||||
case res := <-resCh:
|
||||
result.Results[res.RefId] = res
|
||||
case err := <-errCh:
|
||||
return result, err
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
if len(getMetricDataQueries) > 0 {
|
||||
for region, getMetricDataQuery := range getMetricDataQueries {
|
||||
q := getMetricDataQuery
|
||||
eg.Go(func() error {
|
||||
queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
|
||||
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
|
||||
return err
|
||||
}
|
||||
for _, queryRes := range queryResponses {
|
||||
result.Results[queryRes.RefId] = queryRes
|
||||
if err != nil {
|
||||
result.Results[queryRes.RefId].Error = err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
|
||||
query, err := parseQuery(parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
|
||||
client, err := e.getClient(query.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -201,6 +235,139 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl
|
||||
return queryRes, nil
|
||||
}
|
||||
|
||||
func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, region string, queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) ([]*tsdb.QueryResult, error) {
|
||||
queryResponses := make([]*tsdb.QueryResult, 0)
|
||||
|
||||
// validate query
|
||||
for _, query := range queries {
|
||||
if !(len(query.Statistics) == 1 && len(query.ExtendedStatistics) == 0) &&
|
||||
!(len(query.Statistics) == 0 && len(query.ExtendedStatistics) == 1) {
|
||||
return queryResponses, errors.New("Statistics count should be 1")
|
||||
}
|
||||
}
|
||||
|
||||
client, err := e.getClient(region)
|
||||
if err != nil {
|
||||
return queryResponses, err
|
||||
}
|
||||
|
||||
startTime, err := queryContext.TimeRange.ParseFrom()
|
||||
if err != nil {
|
||||
return queryResponses, err
|
||||
}
|
||||
|
||||
endTime, err := queryContext.TimeRange.ParseTo()
|
||||
if err != nil {
|
||||
return queryResponses, err
|
||||
}
|
||||
|
||||
params := &cloudwatch.GetMetricDataInput{
|
||||
StartTime: aws.Time(startTime),
|
||||
EndTime: aws.Time(endTime),
|
||||
ScanBy: aws.String("TimestampAscending"),
|
||||
}
|
||||
for _, query := range queries {
|
||||
// 1 minutes resolutin metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
|
||||
return nil, errors.New("too long query period")
|
||||
}
|
||||
|
||||
mdq := &cloudwatch.MetricDataQuery{
|
||||
Id: aws.String(query.Id),
|
||||
ReturnData: aws.Bool(query.ReturnData),
|
||||
}
|
||||
if query.Expression != "" {
|
||||
mdq.Expression = aws.String(query.Expression)
|
||||
} else {
|
||||
mdq.MetricStat = &cloudwatch.MetricStat{
|
||||
Metric: &cloudwatch.Metric{
|
||||
Namespace: aws.String(query.Namespace),
|
||||
MetricName: aws.String(query.MetricName),
|
||||
},
|
||||
Period: aws.Int64(int64(query.Period)),
|
||||
}
|
||||
for _, d := range query.Dimensions {
|
||||
mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions,
|
||||
&cloudwatch.Dimension{
|
||||
Name: d.Name,
|
||||
Value: d.Value,
|
||||
})
|
||||
}
|
||||
if len(query.Statistics) == 1 {
|
||||
mdq.MetricStat.Stat = query.Statistics[0]
|
||||
} else {
|
||||
mdq.MetricStat.Stat = query.ExtendedStatistics[0]
|
||||
}
|
||||
}
|
||||
params.MetricDataQueries = append(params.MetricDataQueries, mdq)
|
||||
}
|
||||
|
||||
nextToken := ""
|
||||
mdr := make(map[string]*cloudwatch.MetricDataResult)
|
||||
for {
|
||||
if nextToken != "" {
|
||||
params.NextToken = aws.String(nextToken)
|
||||
}
|
||||
resp, err := client.GetMetricDataWithContext(ctx, params)
|
||||
if err != nil {
|
||||
return queryResponses, err
|
||||
}
|
||||
metrics.M_Aws_CloudWatch_GetMetricData.Add(float64(len(params.MetricDataQueries)))
|
||||
|
||||
for _, r := range resp.MetricDataResults {
|
||||
if _, ok := mdr[*r.Id]; !ok {
|
||||
mdr[*r.Id] = r
|
||||
} else {
|
||||
mdr[*r.Id].Timestamps = append(mdr[*r.Id].Timestamps, r.Timestamps...)
|
||||
mdr[*r.Id].Values = append(mdr[*r.Id].Values, r.Values...)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.NextToken == nil || *resp.NextToken == "" {
|
||||
break
|
||||
}
|
||||
nextToken = *resp.NextToken
|
||||
}
|
||||
|
||||
for i, r := range mdr {
|
||||
if *r.StatusCode != "Complete" {
|
||||
return queryResponses, fmt.Errorf("Part of query is failed: %s", *r.StatusCode)
|
||||
}
|
||||
|
||||
queryRes := tsdb.NewQueryResult()
|
||||
queryRes.RefId = queries[i].RefId
|
||||
query := queries[*r.Id]
|
||||
|
||||
series := tsdb.TimeSeries{
|
||||
Tags: map[string]string{},
|
||||
Points: make([]tsdb.TimePoint, 0),
|
||||
}
|
||||
for _, d := range query.Dimensions {
|
||||
series.Tags[*d.Name] = *d.Value
|
||||
}
|
||||
s := ""
|
||||
if len(query.Statistics) == 1 {
|
||||
s = *query.Statistics[0]
|
||||
} else {
|
||||
s = *query.ExtendedStatistics[0]
|
||||
}
|
||||
series.Name = formatAlias(query, s, series.Tags)
|
||||
|
||||
for j, t := range r.Timestamps {
|
||||
expectedTimestamp := r.Timestamps[j].Add(time.Duration(query.Period) * time.Second)
|
||||
if j > 0 && expectedTimestamp.Before(*t) {
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000)))
|
||||
}
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*r.Values[j]), float64((*t).Unix())*1000))
|
||||
}
|
||||
|
||||
queryRes.Series = append(queryRes.Series, &series)
|
||||
queryResponses = append(queryResponses, queryRes)
|
||||
}
|
||||
|
||||
return queryResponses, nil
|
||||
}
|
||||
|
||||
func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) {
|
||||
var result []*cloudwatch.Dimension
|
||||
|
||||
@ -257,6 +424,9 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := model.Get("id").MustString("")
|
||||
expression := model.Get("expression").MustString("")
|
||||
|
||||
dimensions, err := parseDimensions(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -295,6 +465,7 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
||||
alias = "{{metric}}_{{stat}}"
|
||||
}
|
||||
|
||||
returnData := model.Get("returnData").MustBool(false)
|
||||
highResolution := model.Get("highResolution").MustBool(false)
|
||||
|
||||
return &CloudWatchQuery{
|
||||
@ -306,11 +477,18 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
||||
ExtendedStatistics: aws.StringSlice(extendedStatistics),
|
||||
Period: period,
|
||||
Alias: alias,
|
||||
Id: id,
|
||||
Expression: expression,
|
||||
ReturnData: returnData,
|
||||
HighResolution: highResolution,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string {
|
||||
if len(query.Id) > 0 && len(query.Expression) > 0 {
|
||||
return query.Id
|
||||
}
|
||||
|
||||
data := map[string]string{}
|
||||
data["region"] = query.Region
|
||||
data["namespace"] = query.Namespace
|
||||
@ -338,6 +516,7 @@ func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]stri
|
||||
func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) {
|
||||
queryRes := tsdb.NewQueryResult()
|
||||
|
||||
queryRes.RefId = query.RefId
|
||||
var value float64
|
||||
for _, s := range append(query.Statistics, query.ExtendedStatistics...) {
|
||||
series := tsdb.TimeSeries{
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
)
|
||||
|
||||
type CloudWatchQuery struct {
|
||||
RefId string
|
||||
Region string
|
||||
Namespace string
|
||||
MetricName string
|
||||
@ -13,5 +14,8 @@ type CloudWatchQuery struct {
|
||||
ExtendedStatistics []*string
|
||||
Period int
|
||||
Alias string
|
||||
Id string
|
||||
Expression string
|
||||
ReturnData bool
|
||||
HighResolution bool
|
||||
}
|
||||
|
@ -248,13 +248,28 @@ var datePatternReplacements = map[string]string{
|
||||
|
||||
func formatDate(t time.Time, pattern string) string {
|
||||
var datePattern string
|
||||
parts := strings.Split(strings.TrimLeft(pattern, "["), "]")
|
||||
base := parts[0]
|
||||
if len(parts) == 2 {
|
||||
datePattern = parts[1]
|
||||
} else {
|
||||
datePattern = base
|
||||
base = ""
|
||||
base := ""
|
||||
ltr := false
|
||||
|
||||
if strings.HasPrefix(pattern, "[") {
|
||||
parts := strings.Split(strings.TrimLeft(pattern, "["), "]")
|
||||
base = parts[0]
|
||||
if len(parts) == 2 {
|
||||
datePattern = parts[1]
|
||||
} else {
|
||||
datePattern = base
|
||||
base = ""
|
||||
}
|
||||
ltr = true
|
||||
} else if strings.HasSuffix(pattern, "]") {
|
||||
parts := strings.Split(strings.TrimRight(pattern, "]"), "[")
|
||||
datePattern = parts[0]
|
||||
if len(parts) == 2 {
|
||||
base = parts[1]
|
||||
} else {
|
||||
base = ""
|
||||
}
|
||||
ltr = false
|
||||
}
|
||||
|
||||
formatted := t.Format(patternToLayout(datePattern))
|
||||
@ -293,7 +308,11 @@ func formatDate(t time.Time, pattern string) string {
|
||||
formatted = strings.Replace(formatted, "<stdHourNoZero>", fmt.Sprintf("%d", t.Hour()), -1)
|
||||
}
|
||||
|
||||
return base + formatted
|
||||
if ltr {
|
||||
return base + formatted
|
||||
}
|
||||
|
||||
return formatted + base
|
||||
}
|
||||
|
||||
func patternToLayout(pattern string) string {
|
||||
|
@ -28,29 +28,54 @@ func TestIndexPattern(t *testing.T) {
|
||||
to := fmt.Sprintf("%d", time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond))
|
||||
|
||||
indexPatternScenario(intervalHourly, "[data-]YYYY.MM.DD.HH", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
//So(indices, ShouldHaveLength, 1)
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018.05.15.17")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalHourly, "YYYY.MM.DD.HH[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018.05.15.17-data")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalDaily, "[data-]YYYY.MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018.05.15")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalDaily, "YYYY.MM.DD[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018.05.15-data")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalWeekly, "[data-]GGGG.WW", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018.20")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalWeekly, "GGGG.WW[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018.20-data")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalMonthly, "[data-]YYYY.MM", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018.05")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalMonthly, "YYYY.MM[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018.05-data")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalYearly, "[data-]YYYY", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018")
|
||||
})
|
||||
|
||||
indexPatternScenario(intervalYearly, "YYYY[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018-data")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Hourly interval", t, func() {
|
||||
|
@ -53,7 +53,13 @@ func generateConnectionString(datasource *models.DataSource) string {
|
||||
}
|
||||
|
||||
sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full")
|
||||
u := &url.URL{Scheme: "postgres", User: url.UserPassword(datasource.User, password), Host: datasource.Url, Path: datasource.Database, RawQuery: "sslmode=" + sslmode}
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(datasource.User, password),
|
||||
Host: datasource.Url, Path: datasource.Database,
|
||||
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
|
@ -68,6 +68,7 @@ func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSour
|
||||
engine.SetMaxOpenConns(10)
|
||||
engine.SetMaxIdleConns(10)
|
||||
|
||||
engineCache.versions[dsInfo.Id] = dsInfo.Version
|
||||
engineCache.cache[dsInfo.Id] = engine
|
||||
e.XormEngine = engine
|
||||
|
||||
|
2
pkg/tsdb/testdata/testdata.go
vendored
2
pkg/tsdb/testdata/testdata.go
vendored
@ -21,7 +21,7 @@ func NewTestDataExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, err
|
||||
}
|
||||
|
||||
func init() {
|
||||
tsdb.RegisterTsdbQueryEndpoint("grafana-testdata-datasource", NewTestDataExecutor)
|
||||
tsdb.RegisterTsdbQueryEndpoint("testdata", NewTestDataExecutor)
|
||||
}
|
||||
|
||||
func (e *TestDataExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
|
||||
|
@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import Select from 'react-select';
|
||||
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import colors from 'app/core/utils/colors';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import { decodePathComponent } from 'app/core/utils/location_util';
|
||||
import { parse as parseDate } from 'app/core/utils/datemath';
|
||||
|
||||
import ElapsedTime from './ElapsedTime';
|
||||
import QueryRows from './QueryRows';
|
||||
import Graph from './Graph';
|
||||
import Logs from './Logs';
|
||||
import Table from './Table';
|
||||
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
|
||||
import { decodePathComponent } from 'app/core/utils/location_util';
|
||||
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
|
||||
|
||||
function makeTimeSeriesList(dataList, options) {
|
||||
return dataList.map((seriesData, index) => {
|
||||
@ -30,74 +34,136 @@ function makeTimeSeriesList(dataList, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseInitialState(initial) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodePathComponent(initial));
|
||||
return {
|
||||
queries: parsed.queries.map(q => q.query),
|
||||
range: parsed.range,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { queries: [], range: DEFAULT_RANGE };
|
||||
function parseInitialState(initial: string | undefined) {
|
||||
if (initial) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodePathComponent(initial));
|
||||
return {
|
||||
datasource: parsed.datasource,
|
||||
queries: parsed.queries.map(q => q.query),
|
||||
range: parsed.range,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
interface IExploreState {
|
||||
datasource: any;
|
||||
datasourceError: any;
|
||||
datasourceLoading: any;
|
||||
datasourceLoading: boolean | null;
|
||||
datasourceMissing: boolean;
|
||||
graphResult: any;
|
||||
initialDatasource?: string;
|
||||
latency: number;
|
||||
loading: any;
|
||||
logsResult: any;
|
||||
queries: any;
|
||||
queryError: any;
|
||||
range: any;
|
||||
requestOptions: any;
|
||||
showingGraph: boolean;
|
||||
showingLogs: boolean;
|
||||
showingTable: boolean;
|
||||
supportsGraph: boolean | null;
|
||||
supportsLogs: boolean | null;
|
||||
supportsTable: boolean | null;
|
||||
tableResult: any;
|
||||
}
|
||||
|
||||
// @observer
|
||||
export class Explore extends React.Component<any, IExploreState> {
|
||||
datasourceSrv: DatasourceSrv;
|
||||
el: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { range, queries } = parseInitialState(props.routeParams.initial);
|
||||
const { datasource, queries, range } = parseInitialState(props.routeParams.state);
|
||||
this.state = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: true,
|
||||
datasourceLoading: null,
|
||||
datasourceMissing: false,
|
||||
graphResult: null,
|
||||
initialDatasource: datasource,
|
||||
latency: 0,
|
||||
loading: false,
|
||||
logsResult: null,
|
||||
queries: ensureQueries(queries),
|
||||
queryError: null,
|
||||
range: range || { ...DEFAULT_RANGE },
|
||||
requestOptions: null,
|
||||
showingGraph: true,
|
||||
showingLogs: true,
|
||||
showingTable: true,
|
||||
supportsGraph: null,
|
||||
supportsLogs: null,
|
||||
supportsTable: null,
|
||||
tableResult: null,
|
||||
...props.initialState,
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const datasource = await this.props.datasourceSrv.get();
|
||||
const testResult = await datasource.testDatasource();
|
||||
if (testResult.status === 'success') {
|
||||
this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
|
||||
const { datasourceSrv } = this.props;
|
||||
const { initialDatasource } = this.state;
|
||||
if (!datasourceSrv) {
|
||||
throw new Error('No datasource service passed as props.');
|
||||
}
|
||||
const datasources = datasourceSrv.getExploreSources();
|
||||
if (datasources.length > 0) {
|
||||
this.setState({ datasourceLoading: true });
|
||||
// Priority: datasource in url, default datasource, first explore datasource
|
||||
let datasource;
|
||||
if (initialDatasource) {
|
||||
datasource = await datasourceSrv.get(initialDatasource);
|
||||
} else {
|
||||
datasource = await datasourceSrv.get();
|
||||
}
|
||||
if (!datasource.meta.explore) {
|
||||
datasource = await datasourceSrv.get(datasources[0].name);
|
||||
}
|
||||
this.setDatasource(datasource);
|
||||
} else {
|
||||
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
|
||||
this.setState({ datasourceMissing: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error) {
|
||||
this.setState({ datasourceError: error });
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
async setDatasource(datasource) {
|
||||
const supportsGraph = datasource.meta.metrics;
|
||||
const supportsLogs = datasource.meta.logs;
|
||||
const supportsTable = datasource.meta.metrics;
|
||||
let datasourceError = null;
|
||||
|
||||
try {
|
||||
const testResult = await datasource.testDatasource();
|
||||
datasourceError = testResult.status === 'success' ? null : testResult.message;
|
||||
} catch (error) {
|
||||
datasourceError = (error && error.statusText) || error;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
datasource,
|
||||
datasourceError,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
datasourceLoading: false,
|
||||
},
|
||||
() => datasourceError === null && this.handleSubmit()
|
||||
);
|
||||
}
|
||||
|
||||
getRef = el => {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
handleAddQueryRow = index => {
|
||||
const { queries } = this.state;
|
||||
const nextQueries = [
|
||||
@ -108,6 +174,19 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
this.setState({ queries: nextQueries });
|
||||
};
|
||||
|
||||
handleChangeDatasource = async option => {
|
||||
this.setState({
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: true,
|
||||
graphResult: null,
|
||||
logsResult: null,
|
||||
tableResult: null,
|
||||
});
|
||||
const datasource = await this.props.datasourceSrv.get(option.value);
|
||||
this.setDatasource(datasource);
|
||||
};
|
||||
|
||||
handleChangeQuery = (query, index) => {
|
||||
const { queries } = this.state;
|
||||
const nextQuery = {
|
||||
@ -138,6 +217,10 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
this.setState(state => ({ showingGraph: !state.showingGraph }));
|
||||
};
|
||||
|
||||
handleClickLogsButton = () => {
|
||||
this.setState(state => ({ showingLogs: !state.showingLogs }));
|
||||
};
|
||||
|
||||
handleClickSplit = () => {
|
||||
const { onChangeSplit } = this.props;
|
||||
if (onChangeSplit) {
|
||||
@ -159,29 +242,45 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
};
|
||||
|
||||
handleSubmit = () => {
|
||||
const { showingGraph, showingTable } = this.state;
|
||||
if (showingTable) {
|
||||
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
|
||||
if (showingTable && supportsTable) {
|
||||
this.runTableQuery();
|
||||
}
|
||||
if (showingGraph) {
|
||||
if (showingGraph && supportsGraph) {
|
||||
this.runGraphQuery();
|
||||
}
|
||||
if (showingLogs && supportsLogs) {
|
||||
this.runLogsQuery();
|
||||
}
|
||||
};
|
||||
|
||||
async runGraphQuery() {
|
||||
buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
|
||||
const { datasource, queries, range } = this.state;
|
||||
const resolution = this.el.offsetWidth;
|
||||
const absoluteRange = {
|
||||
from: parseDate(range.from, false),
|
||||
to: parseDate(range.to, true),
|
||||
};
|
||||
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
|
||||
const targets = queries.map(q => ({
|
||||
...targetOptions,
|
||||
expr: q.query,
|
||||
}));
|
||||
return {
|
||||
interval,
|
||||
range,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
async runGraphQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({
|
||||
format: 'time_series',
|
||||
interval: datasource.interval,
|
||||
instant: false,
|
||||
range,
|
||||
queries: queries.map(q => q.query),
|
||||
});
|
||||
const options = this.buildQueryOptions({ format: 'time_series', instant: false });
|
||||
try {
|
||||
const res = await datasource.query(options);
|
||||
const result = makeTimeSeriesList(res.data, options);
|
||||
@ -195,18 +294,15 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
}
|
||||
|
||||
async runTableQuery() {
|
||||
const { datasource, queries, range } = this.state;
|
||||
const { datasource, queries } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({
|
||||
const options = this.buildQueryOptions({
|
||||
format: 'table',
|
||||
interval: datasource.interval,
|
||||
instant: true,
|
||||
range,
|
||||
queries: queries.map(q => q.query),
|
||||
});
|
||||
try {
|
||||
const res = await datasource.query(options);
|
||||
@ -220,35 +316,71 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
async runLogsQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, queryError: null, logsResult: null });
|
||||
const now = Date.now();
|
||||
const options = this.buildQueryOptions({
|
||||
format: 'logs',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await datasource.query(options);
|
||||
const logsData = res.data;
|
||||
const latency = Date.now() - now;
|
||||
this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
|
||||
} catch (response) {
|
||||
console.error(response);
|
||||
const queryError = response.data ? response.data.error : response;
|
||||
this.setState({ loading: false, queryError });
|
||||
}
|
||||
}
|
||||
|
||||
request = url => {
|
||||
const { datasource } = this.state;
|
||||
return datasource.metadataRequest(url);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { position, split } = this.props;
|
||||
const { datasourceSrv, position, split } = this.props;
|
||||
const {
|
||||
datasource,
|
||||
datasourceError,
|
||||
datasourceLoading,
|
||||
datasourceMissing,
|
||||
graphResult,
|
||||
latency,
|
||||
loading,
|
||||
logsResult,
|
||||
queries,
|
||||
queryError,
|
||||
range,
|
||||
requestOptions,
|
||||
showingGraph,
|
||||
showingLogs,
|
||||
showingTable,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
tableResult,
|
||||
} = this.state;
|
||||
const showingBoth = showingGraph && showingTable;
|
||||
const graphHeight = showingBoth ? '200px' : '400px';
|
||||
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
|
||||
const logsButtonActive = showingLogs ? 'active' : '';
|
||||
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
const datasources = datasourceSrv.getExploreSources().map(ds => ({
|
||||
value: ds.name,
|
||||
label: ds.name,
|
||||
}));
|
||||
const selectedDatasource = datasource ? datasource.name : undefined;
|
||||
|
||||
return (
|
||||
<div className={exploreClass}>
|
||||
<div className={exploreClass} ref={this.getRef}>
|
||||
<div className="navbar">
|
||||
{position === 'left' ? (
|
||||
<div>
|
||||
@ -264,6 +396,18 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!datasourceMissing ? (
|
||||
<div className="navbar-buttons">
|
||||
<Select
|
||||
className="datasource-picker"
|
||||
clearable={false}
|
||||
onChange={this.handleChangeDatasource}
|
||||
options={datasources}
|
||||
placeholder="Loading datasources..."
|
||||
value={selectedDatasource}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="navbar__spacer" />
|
||||
{position === 'left' && !split ? (
|
||||
<div className="navbar-buttons">
|
||||
@ -273,12 +417,21 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="navbar-buttons">
|
||||
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
|
||||
Graph
|
||||
</button>
|
||||
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
|
||||
Table
|
||||
</button>
|
||||
{supportsGraph ? (
|
||||
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
|
||||
Graph
|
||||
</button>
|
||||
) : null}
|
||||
{supportsTable ? (
|
||||
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
|
||||
Table
|
||||
</button>
|
||||
) : null}
|
||||
{supportsLogs ? (
|
||||
<button className={`btn navbar-button ${logsButtonActive}`} onClick={this.handleClickLogsButton}>
|
||||
Logs
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<TimePicker range={range} onChangeTime={this.handleChangeTime} />
|
||||
<div className="navbar-buttons relative">
|
||||
@ -291,13 +444,15 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
|
||||
{datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
|
||||
|
||||
{datasourceError ? (
|
||||
<div className="explore-container" title={datasourceError}>
|
||||
Error connecting to datasource.
|
||||
</div>
|
||||
{datasourceMissing ? (
|
||||
<div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
|
||||
) : null}
|
||||
|
||||
{datasource ? (
|
||||
{datasourceError ? (
|
||||
<div className="explore-container">Error connecting to datasource. [{datasourceError}]</div>
|
||||
) : null}
|
||||
|
||||
{datasource && !datasourceError ? (
|
||||
<div className="explore-container">
|
||||
<QueryRows
|
||||
queries={queries}
|
||||
@ -309,7 +464,7 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
/>
|
||||
{queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
|
||||
<main className="m-t-2">
|
||||
{showingGraph ? (
|
||||
{supportsGraph && showingGraph ? (
|
||||
<Graph
|
||||
data={graphResult}
|
||||
id={`explore-graph-${position}`}
|
||||
@ -318,7 +473,8 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
split={split}
|
||||
/>
|
||||
) : null}
|
||||
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
|
||||
{supportsTable && showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
|
||||
{supportsLogs && showingLogs ? <Logs data={logsResult} /> : null}
|
||||
</main>
|
||||
</div>
|
||||
) : null}
|
||||
|
9
public/app/containers/Explore/JSONViewer.tsx
Normal file
9
public/app/containers/Explore/JSONViewer.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function({ value }) {
|
||||
return (
|
||||
<div>
|
||||
<pre>{JSON.stringify(value, undefined, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
66
public/app/containers/Explore/Logs.tsx
Normal file
66
public/app/containers/Explore/Logs.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { Fragment, PureComponent } from 'react';
|
||||
|
||||
import { LogsModel, LogRow } from 'app/core/logs_model';
|
||||
|
||||
interface LogsProps {
|
||||
className?: string;
|
||||
data: LogsModel;
|
||||
}
|
||||
|
||||
const EXAMPLE_QUERY = '{job="default/prometheus"}';
|
||||
|
||||
const Entry: React.SFC<LogRow> = props => {
|
||||
const { entry, searchMatches } = props;
|
||||
if (searchMatches && searchMatches.length > 0) {
|
||||
let lastMatchEnd = 0;
|
||||
const spans = searchMatches.reduce((acc, match, i) => {
|
||||
// Insert non-match
|
||||
if (match.start !== lastMatchEnd) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
|
||||
}
|
||||
// Match
|
||||
acc.push(
|
||||
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
|
||||
{entry.substr(match.start, match.length)}
|
||||
</span>
|
||||
);
|
||||
lastMatchEnd = match.start + match.length;
|
||||
// Non-matching end
|
||||
if (i === searchMatches.length - 1) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd)}</>);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return <>{spans}</>;
|
||||
}
|
||||
return <>{props.entry}</>;
|
||||
};
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, any> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
return (
|
||||
<div className={`${className} logs`}>
|
||||
{hasData ? (
|
||||
<div className="logs-entries panel-container">
|
||||
{data.rows.map(row => (
|
||||
<Fragment key={row.key}>
|
||||
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
|
||||
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
|
||||
<div>
|
||||
<Entry {...row} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasData ? (
|
||||
<div className="panel-container">
|
||||
Enter a query like <code>{EXAMPLE_QUERY}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
125
public/app/containers/Explore/PromQueryField.jest.tsx
Normal file
125
public/app/containers/Explore/PromQueryField.jest.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import Enzyme, { shallow } from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
import PromQueryField from './PromQueryField';
|
||||
|
||||
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 result = instance.getTypeahead({ text: 'j', prefix: 'j', wrapperClasses: ['context-labels'] });
|
||||
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={{ foo: ['bar'] }} />
|
||||
).instance() as PromQueryField;
|
||||
const result = instance.getTypeahead({
|
||||
text: 'job',
|
||||
prefix: 'job',
|
||||
wrapperClasses: ['context-labels'],
|
||||
metric: 'foo',
|
||||
});
|
||||
expect(result.context).toBe('context-labels');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
});
|
||||
|
||||
it('returns a refresher on label context and unavailable metric', () => {
|
||||
const instance = shallow(
|
||||
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
|
||||
).instance() as PromQueryField;
|
||||
const result = instance.getTypeahead({
|
||||
text: 'job',
|
||||
prefix: 'job',
|
||||
wrapperClasses: ['context-labels'],
|
||||
metric: 'xxx',
|
||||
});
|
||||
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={{ foo: ['bar'] }} labelValues={{ foo: { bar: ['baz'] } }} />
|
||||
).instance() as PromQueryField;
|
||||
const result = instance.getTypeahead({
|
||||
text: '=ba',
|
||||
prefix: 'ba',
|
||||
wrapperClasses: ['context-labels'],
|
||||
metric: 'foo',
|
||||
labelKey: 'bar',
|
||||
});
|
||||
expect(result.context).toBe('context-label-values');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values' }]);
|
||||
});
|
||||
|
||||
it('returns label suggestions on aggregation context and metric', () => {
|
||||
const instance = shallow(
|
||||
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
|
||||
).instance() as PromQueryField;
|
||||
const result = instance.getTypeahead({
|
||||
text: 'job',
|
||||
prefix: 'job',
|
||||
wrapperClasses: ['context-aggregation'],
|
||||
metric: 'foo',
|
||||
});
|
||||
expect(result.context).toBe('context-aggregation');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
});
|
||||
});
|
||||
});
|
340
public/app/containers/Explore/PromQueryField.tsx
Normal file
340
public/app/containers/Explore/PromQueryField.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
||||
import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
|
||||
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
|
||||
import RunnerPlugin from './slate-plugins/runner';
|
||||
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
|
||||
|
||||
import TypeaheadField, {
|
||||
Suggestion,
|
||||
SuggestionGroup,
|
||||
TypeaheadInput,
|
||||
TypeaheadFieldState,
|
||||
TypeaheadOutput,
|
||||
} from './QueryField';
|
||||
|
||||
const EMPTY_METRIC = '';
|
||||
const METRIC_MARK = 'metric';
|
||||
const PRISM_LANGUAGE = 'promql';
|
||||
|
||||
export const wrapLabel = label => ({ label });
|
||||
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
|
||||
suggestion.move = -1;
|
||||
return suggestion;
|
||||
};
|
||||
|
||||
export function willApplySuggestion(
|
||||
suggestion: string,
|
||||
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
|
||||
): string {
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) {
|
||||
case 'context-labels': {
|
||||
const nextChar = getNextCharacter();
|
||||
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||
suggestion += '=';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
suggestion = `${suggestion}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
interface PromQueryFieldProps {
|
||||
initialQuery?: string | null;
|
||||
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
metrics?: string[];
|
||||
onPressEnter?: () => void;
|
||||
onQueryChange?: (value: string) => void;
|
||||
portalPrefix?: string;
|
||||
request?: (url: string) => any;
|
||||
}
|
||||
|
||||
interface PromQueryFieldState {
|
||||
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
metrics: string[];
|
||||
}
|
||||
|
||||
interface PromTypeaheadInput {
|
||||
text: string;
|
||||
prefix: string;
|
||||
wrapperClasses: string[];
|
||||
metric?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
|
||||
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
|
||||
plugins: any[];
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.plugins = [
|
||||
RunnerPlugin({ handler: props.onPressEnter }),
|
||||
PluginPrism({ definition: PrismPromql, language: PRISM_LANGUAGE }),
|
||||
];
|
||||
|
||||
this.state = {
|
||||
labelKeys: props.labelKeys || {},
|
||||
labelValues: props.labelValues || {},
|
||||
metrics: props.metrics || [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchMetricNames();
|
||||
}
|
||||
|
||||
onChangeQuery = value => {
|
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props;
|
||||
if (onQueryChange) {
|
||||
onQueryChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
onReceiveMetrics = () => {
|
||||
if (!this.state.metrics) {
|
||||
return;
|
||||
}
|
||||
setPrismTokens(PRISM_LANGUAGE, METRIC_MARK, this.state.metrics);
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
const { editorNode, prefix, text, wrapperNode } = typeahead;
|
||||
|
||||
// Get DOM-dependent context
|
||||
const wrapperClasses = Array.from(wrapperNode.classList);
|
||||
// Take first metric as lucky guess
|
||||
const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
|
||||
const metric = metricNode && metricNode.textContent;
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
const labelKey = labelKeyNode && labelKeyNode.textContent;
|
||||
|
||||
const result = this.getTypeahead({ text, prefix, wrapperClasses, metric, labelKey });
|
||||
|
||||
console.log('handleTypeahead', wrapperClasses, text, prefix, result.context);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Keep this DOM-free for testing
|
||||
getTypeahead({ prefix, wrapperClasses, metric, text }: PromTypeaheadInput): TypeaheadOutput {
|
||||
// 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 (metric && _.includes(wrapperClasses, 'context-aggregation')) {
|
||||
return this.getAggregationTypeahead.apply(this, arguments);
|
||||
} else if (
|
||||
// Non-empty but not inside known token unless it's a metric
|
||||
(prefix && !_.includes(wrapperClasses, 'token')) ||
|
||||
prefix === metric ||
|
||||
(prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
|
||||
text.match(/[+\-*/^%]/) // After binary operator
|
||||
) {
|
||||
return this.getEmptyTypeahead();
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
getEmptyTypeahead(): TypeaheadOutput {
|
||||
const suggestions: SuggestionGroup[] = [];
|
||||
suggestions.push({
|
||||
prefixMatch: true,
|
||||
label: 'Functions',
|
||||
items: FUNCTIONS.map(setFunctionMove),
|
||||
});
|
||||
|
||||
if (this.state.metrics) {
|
||||
suggestions.push({
|
||||
label: 'Metrics',
|
||||
items: this.state.metrics.map(wrapLabel),
|
||||
});
|
||||
}
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
getRangeTypeahead(): TypeaheadOutput {
|
||||
return {
|
||||
context: 'context-range',
|
||||
suggestions: [
|
||||
{
|
||||
label: 'Range vector',
|
||||
items: [...RATE_RANGES].map(wrapLabel),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getAggregationTypeahead({ metric }: PromTypeaheadInput): TypeaheadOutput {
|
||||
let refresher: Promise<any> = null;
|
||||
const suggestions: SuggestionGroup[] = [];
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
||||
} else {
|
||||
refresher = this.fetchMetricLabels(metric);
|
||||
}
|
||||
|
||||
return {
|
||||
refresher,
|
||||
suggestions,
|
||||
context: 'context-aggregation',
|
||||
};
|
||||
}
|
||||
|
||||
getLabelTypeahead({ metric, text, wrapperClasses, labelKey }: PromTypeaheadInput): TypeaheadOutput {
|
||||
let context: string;
|
||||
let refresher: Promise<any> = null;
|
||||
const suggestions: SuggestionGroup[] = [];
|
||||
if (metric) {
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
|
||||
// Label values
|
||||
if (labelKey) {
|
||||
const labelValues = this.state.labelValues[metric][labelKey];
|
||||
context = 'context-label-values';
|
||||
suggestions.push({
|
||||
label: 'Label values',
|
||||
items: labelValues.map(wrapLabel),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
context = 'context-labels';
|
||||
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
||||
}
|
||||
} else {
|
||||
refresher = this.fetchMetricLabels(metric);
|
||||
}
|
||||
} else {
|
||||
// Metric-independent label queries
|
||||
const defaultKeys = ['job', 'instance'];
|
||||
// Munge all keys that we have seen together
|
||||
const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
|
||||
return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
|
||||
}, defaultKeys);
|
||||
if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
|
||||
// Label values
|
||||
if (labelKey) {
|
||||
if (this.state.labelValues[EMPTY_METRIC]) {
|
||||
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
|
||||
context = 'context-label-values';
|
||||
suggestions.push({
|
||||
label: 'Label values',
|
||||
items: labelValues.map(wrapLabel),
|
||||
});
|
||||
} else {
|
||||
// Can only query label values for now (API to query keys is under development)
|
||||
refresher = this.fetchLabelValues(labelKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
context = 'context-labels';
|
||||
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
||||
}
|
||||
}
|
||||
return { context, refresher, suggestions };
|
||||
}
|
||||
|
||||
request = url => {
|
||||
if (this.props.request) {
|
||||
return this.props.request(url);
|
||||
}
|
||||
return fetch(url);
|
||||
};
|
||||
|
||||
async fetchLabelValues(key) {
|
||||
const url = `/api/v1/label/${key}/values`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const pairs = this.state.labelValues[EMPTY_METRIC];
|
||||
const values = {
|
||||
...pairs,
|
||||
[key]: body.data,
|
||||
};
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[EMPTY_METRIC]: values,
|
||||
};
|
||||
this.setState({ labelValues });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricLabels(name) {
|
||||
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);
|
||||
const labelKeys = {
|
||||
...this.state.labelKeys,
|
||||
[name]: keys,
|
||||
};
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[name]: values,
|
||||
};
|
||||
this.setState({ labelKeys, labelValues });
|
||||
} 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());
|
||||
this.setState({ metrics: body.data }, this.onReceiveMetrics);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TypeaheadField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialValue={this.props.initialQuery}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PromQueryField;
|
@ -1,105 +1,163 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Value } from 'slate';
|
||||
import { Block, Change, Document, Text, Value } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
||||
import BracesPlugin from './slate-plugins/braces';
|
||||
import ClearPlugin from './slate-plugins/clear';
|
||||
import NewlinePlugin from './slate-plugins/newline';
|
||||
import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
|
||||
import RunnerPlugin from './slate-plugins/runner';
|
||||
import debounce from './utils/debounce';
|
||||
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
|
||||
|
||||
import Typeahead from './Typeahead';
|
||||
|
||||
const EMPTY_METRIC = '';
|
||||
export const TYPEAHEAD_DEBOUNCE = 300;
|
||||
|
||||
function flattenSuggestions(s) {
|
||||
function flattenSuggestions(s: any[]): any[] {
|
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
||||
}
|
||||
|
||||
export const getInitialValue = query =>
|
||||
Value.fromJSON({
|
||||
document: {
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [
|
||||
{
|
||||
text: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
export const makeFragment = (text: string): Document => {
|
||||
const lines = text.split('\n').map(line =>
|
||||
Block.create({
|
||||
type: 'paragraph',
|
||||
nodes: [Text.create(line)],
|
||||
})
|
||||
);
|
||||
|
||||
const fragment = Document.create({
|
||||
nodes: lines,
|
||||
});
|
||||
return fragment;
|
||||
};
|
||||
|
||||
class Portal extends React.Component<any, any> {
|
||||
node: any;
|
||||
export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { index = 0, prefix = 'query' } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.removeChild(this.node);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
class QueryField extends React.Component<any, any> {
|
||||
menuEl: any;
|
||||
plugins: any;
|
||||
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;
|
||||
}
|
||||
|
||||
interface TypeaheadFieldProps {
|
||||
additionalPlugins?: any[];
|
||||
cleanText?: (text: string) => string;
|
||||
initialValue: string | null;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
|
||||
onValueChanged?: (value: Value) => void;
|
||||
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
|
||||
placeholder?: string;
|
||||
portalPrefix?: string;
|
||||
}
|
||||
|
||||
export interface TypeaheadFieldState {
|
||||
suggestions: SuggestionGroup[];
|
||||
typeaheadContext: string | null;
|
||||
typeaheadIndex: number;
|
||||
typeaheadPrefix: string;
|
||||
typeaheadText: string;
|
||||
value: Value;
|
||||
}
|
||||
|
||||
export interface TypeaheadInput {
|
||||
editorNode: Element;
|
||||
prefix: string;
|
||||
selection?: Selection;
|
||||
text: string;
|
||||
wrapperNode: Element;
|
||||
}
|
||||
|
||||
export interface TypeaheadOutput {
|
||||
context?: string;
|
||||
refresher?: Promise<{}>;
|
||||
suggestions: SuggestionGroup[];
|
||||
}
|
||||
|
||||
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
|
||||
menuEl: HTMLElement | null;
|
||||
plugins: any[];
|
||||
resetTimer: any;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const { prismDefinition = {}, prismLanguage = 'promql' } = props;
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
ClearPlugin(),
|
||||
RunnerPlugin({ handler: props.onPressEnter }),
|
||||
NewlinePlugin(),
|
||||
PluginPrism({ definition: prismDefinition, language: prismLanguage }),
|
||||
];
|
||||
// Base plugins
|
||||
this.plugins = [BracesPlugin(), ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
|
||||
|
||||
this.state = {
|
||||
labelKeys: {},
|
||||
labelValues: {},
|
||||
metrics: props.metrics || [],
|
||||
suggestions: [],
|
||||
typeaheadContext: null,
|
||||
typeaheadIndex: 0,
|
||||
typeaheadPrefix: '',
|
||||
value: getInitialValue(props.initialQuery || ''),
|
||||
typeaheadText: '',
|
||||
value: getInitialValue(props.initialValue || ''),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateMenu();
|
||||
|
||||
if (this.props.metrics === undefined) {
|
||||
this.fetchMetricNames();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@ -111,12 +169,9 @@ class QueryField extends React.Component<any, any> {
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
|
||||
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
|
||||
}
|
||||
// initialQuery is null in case the user typed
|
||||
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
|
||||
this.setState({ value: getInitialValue(nextProps.initialQuery) });
|
||||
// initialValue is null in case the user typed
|
||||
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
|
||||
this.setState({ value: getInitialValue(nextProps.initialValue) });
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,48 +179,28 @@ class QueryField extends React.Component<any, any> {
|
||||
const changed = value.document !== this.state.value.document;
|
||||
this.setState({ value }, () => {
|
||||
if (changed) {
|
||||
this.handleChangeQuery();
|
||||
this.handleChangeValue();
|
||||
}
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(this.handleTypeahead);
|
||||
};
|
||||
|
||||
onMetricsReceived = () => {
|
||||
if (!this.state.metrics) {
|
||||
return;
|
||||
if (changed) {
|
||||
window.requestAnimationFrame(this.handleTypeahead);
|
||||
}
|
||||
setPrismTokens(this.props.prismLanguage, 'metrics', this.state.metrics);
|
||||
|
||||
// Trigger re-render
|
||||
window.requestAnimationFrame(() => {
|
||||
// Bogus edit to trigger highlighting
|
||||
const change = this.state.value
|
||||
.change()
|
||||
.insertText(' ')
|
||||
.deleteBackward(1);
|
||||
this.onChange(change);
|
||||
});
|
||||
};
|
||||
|
||||
request = url => {
|
||||
if (this.props.request) {
|
||||
return this.props.request(url);
|
||||
}
|
||||
return fetch(url);
|
||||
};
|
||||
|
||||
handleChangeQuery = () => {
|
||||
handleChangeValue = () => {
|
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props;
|
||||
if (onQueryChange) {
|
||||
onQueryChange(Plain.serialize(this.state.value));
|
||||
const { onValueChanged } = this.props;
|
||||
if (onValueChanged) {
|
||||
onValueChanged(Plain.serialize(this.state.value));
|
||||
}
|
||||
};
|
||||
|
||||
handleTypeahead = debounce(() => {
|
||||
handleTypeahead = _.debounce(async () => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.anchorNode) {
|
||||
const { cleanText, onTypeahead } = this.props;
|
||||
|
||||
if (onTypeahead && selection.anchorNode) {
|
||||
const wrapperNode = selection.anchorNode.parentElement;
|
||||
const editorNode = wrapperNode.closest('.slate-query-field');
|
||||
if (!editorNode || this.state.value.isBlurred) {
|
||||
@ -174,164 +209,96 @@ class QueryField extends React.Component<any, any> {
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = selection.anchorNode.textContent;
|
||||
const offset = range.startOffset;
|
||||
const prefix = cleanText(text.substr(0, offset));
|
||||
|
||||
// Determine candidates by context
|
||||
const suggestionGroups = [];
|
||||
const wrapperClasses = wrapperNode.classList;
|
||||
let typeaheadContext = null;
|
||||
|
||||
// Take first metric as lucky guess
|
||||
const metricNode = editorNode.querySelector('.metric');
|
||||
|
||||
if (wrapperClasses.contains('context-range')) {
|
||||
// Rate ranges
|
||||
typeaheadContext = 'context-range';
|
||||
suggestionGroups.push({
|
||||
label: 'Range vector',
|
||||
items: [...RATE_RANGES],
|
||||
});
|
||||
} else if (wrapperClasses.contains('context-labels') && metricNode) {
|
||||
const metric = metricNode.textContent;
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
if (labelKeyNode) {
|
||||
const labelKey = labelKeyNode.textContent;
|
||||
const labelValues = this.state.labelValues[metric][labelKey];
|
||||
typeaheadContext = 'context-label-values';
|
||||
suggestionGroups.push({
|
||||
label: 'Label values',
|
||||
items: labelValues,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
typeaheadContext = 'context-labels';
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
}
|
||||
} else {
|
||||
this.fetchMetricLabels(metric);
|
||||
}
|
||||
} else if (wrapperClasses.contains('context-labels') && !metricNode) {
|
||||
// Empty name queries
|
||||
const defaultKeys = ['job', 'instance'];
|
||||
// Munge all keys that we have seen together
|
||||
const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
|
||||
return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
|
||||
}, defaultKeys);
|
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
if (labelKeyNode) {
|
||||
const labelKey = labelKeyNode.textContent;
|
||||
if (this.state.labelValues[EMPTY_METRIC]) {
|
||||
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
|
||||
typeaheadContext = 'context-label-values';
|
||||
suggestionGroups.push({
|
||||
label: 'Label values',
|
||||
items: labelValues,
|
||||
});
|
||||
} else {
|
||||
// Can only query label values for now (API to query keys is under development)
|
||||
this.fetchLabelValues(labelKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
typeaheadContext = 'context-labels';
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
}
|
||||
} else if (metricNode && wrapperClasses.contains('context-aggregation')) {
|
||||
typeaheadContext = 'context-aggregation';
|
||||
const metric = metricNode.textContent;
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
} else {
|
||||
this.fetchMetricLabels(metric);
|
||||
}
|
||||
} else if (
|
||||
(this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
|
||||
wrapperClasses.contains('context-function')
|
||||
) {
|
||||
// Need prefix for metrics
|
||||
typeaheadContext = 'context-metrics';
|
||||
suggestionGroups.push({
|
||||
label: 'Metrics',
|
||||
items: this.state.metrics,
|
||||
});
|
||||
const text = selection.anchorNode.textContent;
|
||||
let prefix = text.substr(0, offset);
|
||||
if (cleanText) {
|
||||
prefix = cleanText(prefix);
|
||||
}
|
||||
|
||||
let results = 0;
|
||||
const filteredSuggestions = suggestionGroups.map(group => {
|
||||
if (group.items) {
|
||||
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
|
||||
results += group.items.length;
|
||||
const { suggestions, context, refresher } = onTypeahead({
|
||||
editorNode,
|
||||
prefix,
|
||||
selection,
|
||||
text,
|
||||
wrapperNode,
|
||||
});
|
||||
|
||||
const filteredSuggestions = suggestions
|
||||
.map(group => {
|
||||
if (group.items) {
|
||||
if (prefix) {
|
||||
// Filter groups based on prefix
|
||||
if (!group.skipFilter) {
|
||||
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
|
||||
if (group.prefixMatch) {
|
||||
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
|
||||
} else {
|
||||
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
|
||||
}
|
||||
}
|
||||
// Filter out the already typed value (prefix) unless it inserts custom text
|
||||
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
|
||||
}
|
||||
|
||||
group.items = _.sortBy(group.items, item => item.sortText || item.label);
|
||||
}
|
||||
return group;
|
||||
})
|
||||
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
|
||||
|
||||
this.setState(
|
||||
{
|
||||
suggestions: filteredSuggestions,
|
||||
typeaheadPrefix: prefix,
|
||||
typeaheadContext: context,
|
||||
typeaheadText: text,
|
||||
},
|
||||
() => {
|
||||
if (refresher) {
|
||||
refresher.then(this.handleTypeahead).catch(e => console.error(e));
|
||||
}
|
||||
}
|
||||
return group;
|
||||
});
|
||||
|
||||
console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
||||
|
||||
this.setState({
|
||||
typeaheadPrefix: prefix,
|
||||
typeaheadContext,
|
||||
typeaheadText: text,
|
||||
suggestions: results > 0 ? filteredSuggestions : [],
|
||||
});
|
||||
);
|
||||
}
|
||||
}, TYPEAHEAD_DEBOUNCE);
|
||||
|
||||
applyTypeahead(change, suggestion) {
|
||||
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
||||
applyTypeahead(change: Change, suggestion: Suggestion): Change {
|
||||
const { cleanText, onWillApplySuggestion } = this.props;
|
||||
const { typeaheadPrefix, typeaheadText } = this.state;
|
||||
let suggestionText = suggestion.insertText || suggestion.label;
|
||||
const move = suggestion.move || 0;
|
||||
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) {
|
||||
case 'context-labels': {
|
||||
const nextChar = getNextCharacter();
|
||||
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||
suggestion += '=';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
suggestion = `${suggestion}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
if (onWillApplySuggestion) {
|
||||
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
|
||||
}
|
||||
|
||||
this.resetTypeahead();
|
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
let backward = typeaheadPrefix.length;
|
||||
const text = cleanText(typeaheadText);
|
||||
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
||||
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
|
||||
const suffixLength = text.length - typeaheadPrefix.length;
|
||||
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
|
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
||||
const forward = midWord ? suffixLength + offset : 0;
|
||||
|
||||
return (
|
||||
change
|
||||
// TODO this line breaks if cursor was moved left and length is longer than whole prefix
|
||||
// If new-lines, apply suggestion as block
|
||||
if (suggestionText.match(/\n/)) {
|
||||
const fragment = makeFragment(suggestionText);
|
||||
return change
|
||||
.deleteBackward(backward)
|
||||
.deleteForward(forward)
|
||||
.insertText(suggestion)
|
||||
.focus()
|
||||
);
|
||||
.insertFragment(fragment)
|
||||
.focus();
|
||||
}
|
||||
|
||||
return change
|
||||
.deleteBackward(backward)
|
||||
.deleteForward(forward)
|
||||
.insertText(suggestionText)
|
||||
.move(move)
|
||||
.focus();
|
||||
}
|
||||
|
||||
onKeyDown = (event, change) => {
|
||||
@ -412,73 +379,6 @@ class QueryField extends React.Component<any, any> {
|
||||
});
|
||||
};
|
||||
|
||||
async fetchLabelValues(key) {
|
||||
const url = `/api/v1/label/${key}/values`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const pairs = this.state.labelValues[EMPTY_METRIC];
|
||||
const values = {
|
||||
...pairs,
|
||||
[key]: body.data,
|
||||
};
|
||||
// const labelKeys = {
|
||||
// ...this.state.labelKeys,
|
||||
// [EMPTY_METRIC]: keys,
|
||||
// };
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[EMPTY_METRIC]: values,
|
||||
};
|
||||
this.setState({ labelValues }, this.handleTypeahead);
|
||||
} catch (e) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(e);
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricLabels(name) {
|
||||
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);
|
||||
const labelKeys = {
|
||||
...this.state.labelKeys,
|
||||
[name]: keys,
|
||||
};
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[name]: values,
|
||||
};
|
||||
this.setState({ labelKeys, labelValues }, this.handleTypeahead);
|
||||
} catch (e) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(e);
|
||||
} else {
|
||||
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());
|
||||
this.setState({ metrics: body.data }, this.onMetricsReceived);
|
||||
} catch (error) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
const { onBlur } = this.props;
|
||||
// If we dont wait here, menu clicks wont work because the menu
|
||||
@ -496,7 +396,7 @@ class QueryField extends React.Component<any, any> {
|
||||
}
|
||||
};
|
||||
|
||||
handleClickMenu = item => {
|
||||
onClickMenu = (item: Suggestion) => {
|
||||
// Manually triggering change
|
||||
const change = this.applyTypeahead(this.state.value.change(), item);
|
||||
this.onChange(change);
|
||||
@ -529,7 +429,7 @@ class QueryField extends React.Component<any, any> {
|
||||
|
||||
// Write DOM
|
||||
requestAnimationFrame(() => {
|
||||
menu.style.opacity = 1;
|
||||
menu.style.opacity = '1';
|
||||
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
||||
menu.style.left = `${rect.left + scrollX - 2}px`;
|
||||
});
|
||||
@ -552,17 +452,16 @@ class QueryField extends React.Component<any, any> {
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
||||
const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
|
||||
i => (typeof i === 'object' ? i.text : i)
|
||||
);
|
||||
const selectedItem: Suggestion | null =
|
||||
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
<Portal prefix={portalPrefix}>
|
||||
<Typeahead
|
||||
menuRef={this.menuRef}
|
||||
selectedItems={selectedKeys}
|
||||
onClickItem={this.handleClickMenu}
|
||||
selectedItem={selectedItem}
|
||||
onClickItem={this.onClickMenu}
|
||||
groupedItems={suggestions}
|
||||
/>
|
||||
</Portal>
|
||||
@ -589,4 +488,24 @@ class QueryField extends React.Component<any, any> {
|
||||
}
|
||||
}
|
||||
|
||||
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
|
||||
node: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { index = 0, prefix = 'query' } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.removeChild(this.node);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
}
|
||||
}
|
||||
|
||||
export default QueryField;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import promql from './slate-plugins/prism/promql';
|
||||
import QueryField from './QueryField';
|
||||
import QueryField from './PromQueryField';
|
||||
|
||||
class QueryRow extends PureComponent<any, any> {
|
||||
constructor(props) {
|
||||
@ -62,9 +61,6 @@ class QueryRow extends PureComponent<any, any> {
|
||||
portalPrefix="explore"
|
||||
onPressEnter={this.handlePressEnter}
|
||||
onQueryChange={this.handleChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
prismLanguage="promql"
|
||||
prismDefinition={promql}
|
||||
request={request}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,17 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
function scrollIntoView(el) {
|
||||
import { Suggestion, SuggestionGroup } from './QueryField';
|
||||
|
||||
function scrollIntoView(el: HTMLElement) {
|
||||
if (!el || !el.offsetParent) {
|
||||
return;
|
||||
}
|
||||
const container = el.offsetParent;
|
||||
const container = el.offsetParent as HTMLElement;
|
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
|
||||
container.scrollTop = el.offsetTop - container.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<any, any> {
|
||||
el: any;
|
||||
interface TypeaheadItemProps {
|
||||
isSelected: boolean;
|
||||
item: Suggestion;
|
||||
onClickItem: (Suggestion) => void;
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
el: HTMLElement;
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.isSelected && !prevProps.isSelected) {
|
||||
scrollIntoView(this.el);
|
||||
@ -22,20 +31,30 @@ class TypeaheadItem extends React.PureComponent<any, any> {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
this.props.onClickItem(this.props.item);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hint, isSelected, label, onClickItem } = this.props;
|
||||
const { isSelected, item } = this.props;
|
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||
const onClick = () => onClickItem(label);
|
||||
return (
|
||||
<li ref={this.getRef} className={className} onClick={onClick}>
|
||||
{label}
|
||||
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
|
||||
<li ref={this.getRef} className={className} onClick={this.onClick}>
|
||||
{item.detail || item.label}
|
||||
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||
interface TypeaheadGroupProps {
|
||||
items: Suggestion[];
|
||||
label: string;
|
||||
onClickItem: (Suggestion) => void;
|
||||
selected: Suggestion;
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
|
||||
render() {
|
||||
const { items, label, selected, onClickItem } = this.props;
|
||||
return (
|
||||
@ -43,16 +62,8 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||
<div className="typeahead-group__title">{label}</div>
|
||||
<ul className="typeahead-group__list">
|
||||
{items.map(item => {
|
||||
const text = typeof item === 'object' ? item.text : item;
|
||||
const label = typeof item === 'object' ? item.display || item.text : item;
|
||||
return (
|
||||
<TypeaheadItem
|
||||
key={text}
|
||||
onClickItem={onClickItem}
|
||||
isSelected={selected.indexOf(text) > -1}
|
||||
hint={item.hint}
|
||||
label={label}
|
||||
/>
|
||||
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
@ -61,13 +72,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||
}
|
||||
}
|
||||
|
||||
class Typeahead extends React.PureComponent<any, any> {
|
||||
interface TypeaheadProps {
|
||||
groupedItems: SuggestionGroup[];
|
||||
menuRef: any;
|
||||
selectedItem: Suggestion | null;
|
||||
onClickItem: (Suggestion) => void;
|
||||
}
|
||||
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
|
||||
render() {
|
||||
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
|
||||
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
|
||||
return (
|
||||
<ul className="typeahead" ref={menuRef}>
|
||||
{groupedItems.map(g => (
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
@ -1,67 +1,368 @@
|
||||
/* tslint:disable max-line-length */
|
||||
|
||||
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
|
||||
|
||||
const AGGREGATION_OPERATORS = [
|
||||
'sum',
|
||||
'min',
|
||||
'max',
|
||||
'avg',
|
||||
'stddev',
|
||||
'stdvar',
|
||||
'count',
|
||||
'count_values',
|
||||
'bottomk',
|
||||
'topk',
|
||||
'quantile',
|
||||
{
|
||||
label: 'sum',
|
||||
insertText: 'sum()',
|
||||
documentation: 'Calculate sum over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'min',
|
||||
insertText: 'min()',
|
||||
documentation: 'Select minimum over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'max',
|
||||
insertText: 'max()',
|
||||
documentation: 'Select maximum over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'avg',
|
||||
insertText: 'avg()',
|
||||
documentation: 'Calculate the average over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'stddev',
|
||||
insertText: 'stddev()',
|
||||
documentation: 'Calculate population standard deviation over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'stdvar',
|
||||
insertText: 'stdvar()',
|
||||
documentation: 'Calculate population standard variance over dimensions',
|
||||
},
|
||||
{
|
||||
label: 'count',
|
||||
insertText: 'count()',
|
||||
documentation: 'Count number of elements in the vector',
|
||||
},
|
||||
{
|
||||
label: 'count_values',
|
||||
insertText: 'count_values()',
|
||||
documentation: 'Count number of elements with the same value',
|
||||
},
|
||||
{
|
||||
label: 'bottomk',
|
||||
insertText: 'bottomk()',
|
||||
documentation: 'Smallest k elements by sample value',
|
||||
},
|
||||
{
|
||||
label: 'topk',
|
||||
insertText: 'topk()',
|
||||
documentation: 'Largest k elements by sample value',
|
||||
},
|
||||
{
|
||||
label: 'quantile',
|
||||
insertText: 'quantile()',
|
||||
documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions',
|
||||
},
|
||||
];
|
||||
|
||||
export const FUNCTIONS = [
|
||||
...AGGREGATION_OPERATORS,
|
||||
'abs',
|
||||
'absent',
|
||||
'ceil',
|
||||
'changes',
|
||||
'clamp_max',
|
||||
'clamp_min',
|
||||
'count_scalar',
|
||||
'day_of_month',
|
||||
'day_of_week',
|
||||
'days_in_month',
|
||||
'delta',
|
||||
'deriv',
|
||||
'drop_common_labels',
|
||||
'exp',
|
||||
'floor',
|
||||
'histogram_quantile',
|
||||
'holt_winters',
|
||||
'hour',
|
||||
'idelta',
|
||||
'increase',
|
||||
'irate',
|
||||
'label_replace',
|
||||
'ln',
|
||||
'log2',
|
||||
'log10',
|
||||
'minute',
|
||||
'month',
|
||||
'predict_linear',
|
||||
'rate',
|
||||
'resets',
|
||||
'round',
|
||||
'scalar',
|
||||
'sort',
|
||||
'sort_desc',
|
||||
'sqrt',
|
||||
'time',
|
||||
'vector',
|
||||
'year',
|
||||
'avg_over_time',
|
||||
'min_over_time',
|
||||
'max_over_time',
|
||||
'sum_over_time',
|
||||
'count_over_time',
|
||||
'quantile_over_time',
|
||||
'stddev_over_time',
|
||||
'stdvar_over_time',
|
||||
{
|
||||
insertText: 'abs()',
|
||||
label: 'abs',
|
||||
detail: 'abs(v instant-vector)',
|
||||
documentation: 'Returns the input vector with all sample values converted to their absolute value.',
|
||||
},
|
||||
{
|
||||
insertText: 'absent()',
|
||||
label: 'absent',
|
||||
detail: 'absent(v instant-vector)',
|
||||
documentation:
|
||||
'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.',
|
||||
},
|
||||
{
|
||||
insertText: 'ceil()',
|
||||
label: 'ceil',
|
||||
detail: 'ceil(v instant-vector)',
|
||||
documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
|
||||
},
|
||||
{
|
||||
insertText: 'changes()',
|
||||
label: 'changes',
|
||||
detail: 'changes(v range-vector)',
|
||||
documentation:
|
||||
'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.',
|
||||
},
|
||||
{
|
||||
insertText: 'clamp_max()',
|
||||
label: 'clamp_max',
|
||||
detail: 'clamp_max(v instant-vector, max scalar)',
|
||||
documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
|
||||
},
|
||||
{
|
||||
insertText: 'clamp_min()',
|
||||
label: 'clamp_min',
|
||||
detail: 'clamp_min(v instant-vector, min scalar)',
|
||||
documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
|
||||
},
|
||||
{
|
||||
insertText: 'count_scalar()',
|
||||
label: 'count_scalar',
|
||||
detail: 'count_scalar(v instant-vector)',
|
||||
documentation:
|
||||
'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.',
|
||||
},
|
||||
{
|
||||
insertText: 'day_of_month()',
|
||||
label: 'day_of_month',
|
||||
detail: 'day_of_month(v=vector(time()) instant-vector)',
|
||||
documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.',
|
||||
},
|
||||
{
|
||||
insertText: 'day_of_week()',
|
||||
label: 'day_of_week',
|
||||
detail: 'day_of_week(v=vector(time()) instant-vector)',
|
||||
documentation:
|
||||
'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.',
|
||||
},
|
||||
{
|
||||
insertText: 'days_in_month()',
|
||||
label: 'days_in_month',
|
||||
detail: 'days_in_month(v=vector(time()) instant-vector)',
|
||||
documentation:
|
||||
'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.',
|
||||
},
|
||||
{
|
||||
insertText: 'delta()',
|
||||
label: 'delta',
|
||||
detail: 'delta(v range-vector)',
|
||||
documentation:
|
||||
'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.',
|
||||
},
|
||||
{
|
||||
insertText: 'deriv()',
|
||||
label: 'deriv',
|
||||
detail: 'deriv(v range-vector)',
|
||||
documentation:
|
||||
'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.',
|
||||
},
|
||||
{
|
||||
insertText: 'drop_common_labels()',
|
||||
label: 'drop_common_labels',
|
||||
detail: 'drop_common_labels(instant-vector)',
|
||||
documentation: 'Drops all labels that have the same name and value across all series in the input vector.',
|
||||
},
|
||||
{
|
||||
insertText: 'exp()',
|
||||
label: 'exp',
|
||||
detail: 'exp(v instant-vector)',
|
||||
documentation:
|
||||
'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`',
|
||||
},
|
||||
{
|
||||
insertText: 'floor()',
|
||||
label: 'floor',
|
||||
detail: 'floor(v instant-vector)',
|
||||
documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.',
|
||||
},
|
||||
{
|
||||
insertText: 'histogram_quantile()',
|
||||
label: 'histogram_quantile',
|
||||
detail: 'histogram_quantile(φ float, b instant-vector)',
|
||||
documentation:
|
||||
'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.',
|
||||
},
|
||||
{
|
||||
insertText: 'holt_winters()',
|
||||
label: 'holt_winters',
|
||||
detail: 'holt_winters(v range-vector, sf scalar, tf scalar)',
|
||||
documentation:
|
||||
'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.',
|
||||
},
|
||||
{
|
||||
insertText: 'hour()',
|
||||
label: 'hour',
|
||||
detail: 'hour(v=vector(time()) instant-vector)',
|
||||
documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.',
|
||||
},
|
||||
{
|
||||
insertText: 'idelta()',
|
||||
label: 'idelta',
|
||||
detail: 'idelta(v range-vector)',
|
||||
documentation:
|
||||
'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.',
|
||||
},
|
||||
{
|
||||
insertText: 'increase()',
|
||||
label: 'increase',
|
||||
detail: 'increase(v range-vector)',
|
||||
documentation:
|
||||
'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.',
|
||||
},
|
||||
{
|
||||
insertText: 'irate()',
|
||||
label: 'irate',
|
||||
detail: 'irate(v range-vector)',
|
||||
documentation:
|
||||
'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.',
|
||||
},
|
||||
{
|
||||
insertText: 'label_replace()',
|
||||
label: 'label_replace',
|
||||
detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
|
||||
documentation:
|
||||
"For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.",
|
||||
},
|
||||
{
|
||||
insertText: 'ln()',
|
||||
label: 'ln',
|
||||
detail: 'ln(v instant-vector)',
|
||||
documentation:
|
||||
'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`',
|
||||
},
|
||||
{
|
||||
insertText: 'log2()',
|
||||
label: 'log2',
|
||||
detail: 'log2(v instant-vector)',
|
||||
documentation:
|
||||
'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
|
||||
},
|
||||
{
|
||||
insertText: 'log10()',
|
||||
label: 'log10',
|
||||
detail: 'log10(v instant-vector)',
|
||||
documentation:
|
||||
'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
|
||||
},
|
||||
{
|
||||
insertText: 'minute()',
|
||||
label: 'minute',
|
||||
detail: 'minute(v=vector(time()) instant-vector)',
|
||||
documentation:
|
||||
'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.',
|
||||
},
|
||||
{
|
||||
insertText: 'month()',
|
||||
label: 'month',
|
||||
detail: 'month(v=vector(time()) instant-vector)',
|
||||
documentation:
|
||||
'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.',
|
||||
},
|
||||
{
|
||||
insertText: 'predict_linear()',
|
||||
label: 'predict_linear',
|
||||
detail: 'predict_linear(v range-vector, t scalar)',
|
||||
documentation:
|
||||
'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.',
|
||||
},
|
||||
{
|
||||
insertText: 'rate()',
|
||||
label: 'rate',
|
||||
detail: 'rate(v range-vector)',
|
||||
documentation:
|
||||
"Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.",
|
||||
},
|
||||
{
|
||||
insertText: 'resets()',
|
||||
label: 'resets',
|
||||
detail: 'resets(v range-vector)',
|
||||
documentation:
|
||||
'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.',
|
||||
},
|
||||
{
|
||||
insertText: 'round()',
|
||||
label: 'round',
|
||||
detail: 'round(v instant-vector, to_nearest=1 scalar)',
|
||||
documentation:
|
||||
'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.',
|
||||
},
|
||||
{
|
||||
insertText: 'scalar()',
|
||||
label: 'scalar',
|
||||
detail: 'scalar(v instant-vector)',
|
||||
documentation:
|
||||
'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.',
|
||||
},
|
||||
{
|
||||
insertText: 'sort()',
|
||||
label: 'sort',
|
||||
detail: 'sort(v instant-vector)',
|
||||
documentation: 'Returns vector elements sorted by their sample values, in ascending order.',
|
||||
},
|
||||
{
|
||||
insertText: 'sort_desc()',
|
||||
label: 'sort_desc',
|
||||
detail: 'sort_desc(v instant-vector)',
|
||||
documentation: 'Returns vector elements sorted by their sample values, in descending order.',
|
||||
},
|
||||
{
|
||||
insertText: 'sqrt()',
|
||||
label: 'sqrt',
|
||||
detail: 'sqrt(v instant-vector)',
|
||||
documentation: 'Calculates the square root of all elements in `v`.',
|
||||
},
|
||||
{
|
||||
insertText: 'time()',
|
||||
label: 'time',
|
||||
detail: 'time()',
|
||||
documentation:
|
||||
'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.',
|
||||
},
|
||||
{
|
||||
insertText: 'vector()',
|
||||
label: 'vector',
|
||||
detail: 'vector(s scalar)',
|
||||
documentation: 'Returns the scalar `s` as a vector with no labels.',
|
||||
},
|
||||
{
|
||||
insertText: 'year()',
|
||||
label: 'year',
|
||||
detail: 'year(v=vector(time()) instant-vector)',
|
||||
documentation: 'Returns the year for each of the given times in UTC.',
|
||||
},
|
||||
{
|
||||
insertText: 'avg_over_time()',
|
||||
label: 'avg_over_time',
|
||||
detail: 'avg_over_time(range-vector)',
|
||||
documentation: 'The average value of all points in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'min_over_time()',
|
||||
label: 'min_over_time',
|
||||
detail: 'min_over_time(range-vector)',
|
||||
documentation: 'The minimum value of all points in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'max_over_time()',
|
||||
label: 'max_over_time',
|
||||
detail: 'max_over_time(range-vector)',
|
||||
documentation: 'The maximum value of all points in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'sum_over_time()',
|
||||
label: 'sum_over_time',
|
||||
detail: 'sum_over_time(range-vector)',
|
||||
documentation: 'The sum of all values in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'count_over_time()',
|
||||
label: 'count_over_time',
|
||||
detail: 'count_over_time(range-vector)',
|
||||
documentation: 'The count of all values in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'quantile_over_time()',
|
||||
label: 'quantile_over_time',
|
||||
detail: 'quantile_over_time(scalar, range-vector)',
|
||||
documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'stddev_over_time()',
|
||||
label: 'stddev_over_time',
|
||||
detail: 'stddev_over_time(range-vector)',
|
||||
documentation: 'The population standard deviation of the values in the specified interval.',
|
||||
},
|
||||
{
|
||||
insertText: 'stdvar_over_time()',
|
||||
label: 'stdvar_over_time',
|
||||
detail: 'stdvar_over_time(range-vector)',
|
||||
documentation: 'The population standard variance of the values in the specified interval.',
|
||||
},
|
||||
];
|
||||
|
||||
const tokenizer = {
|
||||
@ -93,7 +394,7 @@ const tokenizer = {
|
||||
},
|
||||
},
|
||||
},
|
||||
function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
|
||||
function: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})(?=\\s*\\()`, 'i'),
|
||||
'context-range': [
|
||||
{
|
||||
pattern: /\[[^\]]*(?=])/, // [1m]
|
||||
|
@ -1,15 +1,3 @@
|
||||
export function buildQueryOptions({ format, interval, instant, range, queries }) {
|
||||
return {
|
||||
interval,
|
||||
range,
|
||||
targets: queries.map(expr => ({
|
||||
expr,
|
||||
format,
|
||||
instant,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateQueryKey(index = 0) {
|
||||
return `Q-${Date.now()}-${Math.random()}-${index}`;
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
|
||||
<PageHeader model={nav as any} />
|
||||
<div className="page-container page-body">
|
||||
<div className="page-action-bar">
|
||||
<h2 className="d-inline-block">Folder Permissions</h2>
|
||||
<h3 className="page-sub-heading">Folder Permissions</h3>
|
||||
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
@ -68,7 +68,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
|
||||
</button>
|
||||
</div>
|
||||
<SlideDown in={permissions.isAddPermissionsVisible}>
|
||||
<AddPermissions permissions={permissions} backendSrv={backendSrv} />
|
||||
<AddPermissions permissions={permissions} />
|
||||
</SlideDown>
|
||||
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
|
||||
</div>
|
||||
|
149
public/app/containers/Teams/TeamGroupSync.tsx
Normal file
149
public/app/containers/Teams/TeamGroupSync.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { observer } from 'mobx-react';
|
||||
import { ITeam, ITeamGroup } from 'app/stores/TeamsStore/TeamsStore';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
team: ITeam;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isAdding: boolean;
|
||||
newGroupId?: string;
|
||||
}
|
||||
|
||||
const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
|
||||
|
||||
@observer
|
||||
export class TeamGroupSync extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isAdding: false, newGroupId: '' };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.team.loadGroups();
|
||||
}
|
||||
|
||||
renderGroup(group: ITeamGroup) {
|
||||
return (
|
||||
<tr key={group.groupId}>
|
||||
<td>{group.groupId}</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<a className="btn btn-danger btn-mini" onClick={() => this.onRemoveGroup(group)}>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
onToggleAdding = () => {
|
||||
this.setState({ isAdding: !this.state.isAdding });
|
||||
};
|
||||
|
||||
onNewGroupIdChanged = evt => {
|
||||
this.setState({ newGroupId: evt.target.value });
|
||||
};
|
||||
|
||||
onAddGroup = () => {
|
||||
this.props.team.addGroup(this.state.newGroupId);
|
||||
this.setState({ isAdding: false, newGroupId: '' });
|
||||
};
|
||||
|
||||
onRemoveGroup = (group: ITeamGroup) => {
|
||||
this.props.team.removeGroup(group.groupId);
|
||||
};
|
||||
|
||||
isNewGroupValid() {
|
||||
return this.state.newGroupId.length > 1;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isAdding, newGroupId } = this.state;
|
||||
const groups = this.props.team.groups.values();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
<h3 className="page-sub-heading">External group sync</h3>
|
||||
<Tooltip className="page-sub-heading-icon" placement="auto" content={headerTooltip}>
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
<div className="page-action-bar__spacer" />
|
||||
{groups.length > 0 && (
|
||||
<button className="btn btn-success pull-right" onClick={this.onToggleAdding}>
|
||||
<i className="fa fa-plus" /> Add group
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SlideDown in={isAdding}>
|
||||
<div className="cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<h5>Add External Group</h5>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-30"
|
||||
value={newGroupId}
|
||||
onChange={this.onNewGroupIdChanged}
|
||||
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<button
|
||||
className="btn btn-success gf-form-btn"
|
||||
onClick={this.onAddGroup}
|
||||
type="submit"
|
||||
disabled={!this.isNewGroupValid()}
|
||||
>
|
||||
Add group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlideDown>
|
||||
|
||||
{groups.length === 0 &&
|
||||
!isAdding && (
|
||||
<div className="empty-list-cta">
|
||||
<div className="empty-list-cta__title">There are no external groups to sync with</div>
|
||||
<button onClick={this.onToggleAdding} className="empty-list-cta__button btn btn-xlarge btn-success">
|
||||
<i className="gicon gicon-add-team" />
|
||||
Add Group
|
||||
</button>
|
||||
<div className="empty-list-cta__pro-tip">
|
||||
<i className="fa fa-rocket" /> {headerTooltip}
|
||||
<a className="text-link empty-list-cta__pro-tip-link" href="asd" target="_blank">
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="admin-list-table">
|
||||
<table className="filter-table filter-table--hover form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>External Group ID</th>
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{groups.map(group => this.renderGroup(group))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(TeamGroupSync);
|
125
public/app/containers/Teams/TeamList.tsx
Normal file
125
public/app/containers/Teams/TeamList.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import { NavStore } from 'app/stores/NavStore/NavStore';
|
||||
import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
interface Props {
|
||||
nav: typeof NavStore.Type;
|
||||
teams: typeof TeamsStore.Type;
|
||||
backendSrv: BackendSrv;
|
||||
}
|
||||
|
||||
@inject('nav', 'teams')
|
||||
@observer
|
||||
export class TeamList extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.props.nav.load('cfg', 'teams');
|
||||
this.fetchTeams();
|
||||
}
|
||||
|
||||
fetchTeams() {
|
||||
this.props.teams.loadTeams();
|
||||
}
|
||||
|
||||
deleteTeam(team: ITeam) {
|
||||
appEvents.emit('confirm-modal', {
|
||||
title: 'Delete',
|
||||
text: 'Are you sure you want to delete Team ' + team.name + '?',
|
||||
yesText: 'Delete',
|
||||
icon: 'fa-warning',
|
||||
onConfirm: () => {
|
||||
this.deleteTeamConfirmed(team);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteTeamConfirmed(team) {
|
||||
this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this));
|
||||
}
|
||||
|
||||
onSearchQueryChange = evt => {
|
||||
this.props.teams.setSearchQuery(evt.target.value);
|
||||
};
|
||||
|
||||
renderTeamMember(team: ITeam): JSX.Element {
|
||||
let teamUrl = `org/teams/edit/${team.id}`;
|
||||
|
||||
return (
|
||||
<tr key={team.id}>
|
||||
<td className="width-4 text-center link-td">
|
||||
<a href={teamUrl}>
|
||||
<img className="filter-table__avatar" src={team.avatarUrl} />
|
||||
</a>
|
||||
</td>
|
||||
<td className="link-td">
|
||||
<a href={teamUrl}>{team.name}</a>
|
||||
</td>
|
||||
<td className="link-td">
|
||||
<a href={teamUrl}>{team.email}</a>
|
||||
</td>
|
||||
<td className="link-td">
|
||||
<a href={teamUrl}>{team.memberCount}</a>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<a onClick={() => this.deleteTeam(team)} className="btn btn-danger btn-small">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { nav, teams } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={nav as any} />
|
||||
<div className="page-container page-body">
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon gf-form--grow">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
placeholder="Search teams"
|
||||
value={teams.search}
|
||||
onChange={this.onSearchQueryChange}
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="page-action-bar__spacer" />
|
||||
|
||||
<a className="btn btn-success" href="org/teams/new">
|
||||
<i className="fa fa-plus" /> New team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-table">
|
||||
<table className="filter-table filter-table--hover form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Members</th>
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(TeamList);
|
144
public/app/containers/Teams/TeamMembers.tsx
Normal file
144
public/app/containers/Teams/TeamMembers.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { observer } from 'mobx-react';
|
||||
import { ITeam, ITeamMember } from 'app/stores/TeamsStore/TeamsStore';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
|
||||
|
||||
interface Props {
|
||||
team: ITeam;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isAdding: boolean;
|
||||
newTeamMember?: User;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class TeamMembers extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isAdding: false, newTeamMember: null };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.team.loadMembers();
|
||||
}
|
||||
|
||||
onSearchQueryChange = evt => {
|
||||
this.props.team.setSearchQuery(evt.target.value);
|
||||
};
|
||||
|
||||
removeMember(member: ITeamMember) {
|
||||
appEvents.emit('confirm-modal', {
|
||||
title: 'Remove Member',
|
||||
text: 'Are you sure you want to remove ' + member.login + ' from this group?',
|
||||
yesText: 'Remove',
|
||||
icon: 'fa-warning',
|
||||
onConfirm: () => {
|
||||
this.removeMemberConfirmed(member);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
removeMemberConfirmed(member: ITeamMember) {
|
||||
this.props.team.removeMember(member);
|
||||
}
|
||||
|
||||
renderMember(member: ITeamMember) {
|
||||
return (
|
||||
<tr key={member.userId}>
|
||||
<td className="width-4 text-center">
|
||||
<img className="filter-table__avatar" src={member.avatarUrl} />
|
||||
</td>
|
||||
<td>{member.login}</td>
|
||||
<td>{member.email}</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<a onClick={() => this.removeMember(member)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
onToggleAdding = () => {
|
||||
this.setState({ isAdding: !this.state.isAdding });
|
||||
};
|
||||
|
||||
onUserSelected = (user: User) => {
|
||||
this.setState({ newTeamMember: user });
|
||||
};
|
||||
|
||||
onAddUserToTeam = async () => {
|
||||
await this.props.team.addMember(this.state.newTeamMember.id);
|
||||
await this.props.team.loadMembers();
|
||||
this.setState({ newTeamMember: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newTeamMember, isAdding } = this.state;
|
||||
const members = this.props.team.members.values();
|
||||
const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon gf-form--grow">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
placeholder="Search members"
|
||||
value={''}
|
||||
onChange={this.onSearchQueryChange}
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="page-action-bar__spacer" />
|
||||
|
||||
<button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
|
||||
<i className="fa fa-plus" /> Add a member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SlideDown in={isAdding}>
|
||||
<div className="cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<h5>Add Team Member</h5>
|
||||
<div className="gf-form-inline">
|
||||
<UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
|
||||
|
||||
{this.state.newTeamMember && (
|
||||
<button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
|
||||
Add to team
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SlideDown>
|
||||
|
||||
<div className="admin-list-table">
|
||||
<table className="filter-table filter-table--hover form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{members.map(member => this.renderMember(member))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(TeamMembers);
|
77
public/app/containers/Teams/TeamPages.tsx
Normal file
77
public/app/containers/Teams/TeamPages.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import config from 'app/core/config';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import { NavStore } from 'app/stores/NavStore/NavStore';
|
||||
import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
|
||||
import { ViewStore } from 'app/stores/ViewStore/ViewStore';
|
||||
import TeamMembers from './TeamMembers';
|
||||
import TeamSettings from './TeamSettings';
|
||||
import TeamGroupSync from './TeamGroupSync';
|
||||
|
||||
interface Props {
|
||||
nav: typeof NavStore.Type;
|
||||
teams: typeof TeamsStore.Type;
|
||||
view: typeof ViewStore.Type;
|
||||
}
|
||||
|
||||
@inject('nav', 'teams', 'view')
|
||||
@observer
|
||||
export class TeamPages extends React.Component<Props, any> {
|
||||
isSyncEnabled: boolean;
|
||||
currentPage: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.isSyncEnabled = config.buildInfo.isEnterprise;
|
||||
this.currentPage = this.getCurrentPage();
|
||||
|
||||
this.loadTeam();
|
||||
}
|
||||
|
||||
async loadTeam() {
|
||||
const { teams, nav, view } = this.props;
|
||||
|
||||
await teams.loadById(view.routeParams.get('id'));
|
||||
|
||||
nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled);
|
||||
}
|
||||
|
||||
getCurrentTeam(): ITeam {
|
||||
const { teams, view } = this.props;
|
||||
return teams.map.get(view.routeParams.get('id'));
|
||||
}
|
||||
|
||||
getCurrentPage() {
|
||||
const pages = ['members', 'settings', 'groupsync'];
|
||||
const currentPage = this.props.view.routeParams.get('page');
|
||||
return _.includes(pages, currentPage) ? currentPage : pages[0];
|
||||
}
|
||||
|
||||
render() {
|
||||
const { nav } = this.props;
|
||||
const currentTeam = this.getCurrentTeam();
|
||||
|
||||
if (!nav.main) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={nav as any} />
|
||||
{currentTeam && (
|
||||
<div className="page-container page-body">
|
||||
{this.currentPage === 'members' && <TeamMembers team={currentTeam} />}
|
||||
{this.currentPage === 'settings' && <TeamSettings team={currentTeam} />}
|
||||
{this.currentPage === 'groupsync' && this.isSyncEnabled && <TeamGroupSync team={currentTeam} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(TeamPages);
|
69
public/app/containers/Teams/TeamSettings.tsx
Normal file
69
public/app/containers/Teams/TeamSettings.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { observer } from 'mobx-react';
|
||||
import { ITeam } from 'app/stores/TeamsStore/TeamsStore';
|
||||
import { Label } from 'app/core/components/Forms/Forms';
|
||||
|
||||
interface Props {
|
||||
team: ITeam;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class TeamSettings extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onChangeName = evt => {
|
||||
this.props.team.setName(evt.target.value);
|
||||
};
|
||||
|
||||
onChangeEmail = evt => {
|
||||
this.props.team.setEmail(evt.target.value);
|
||||
};
|
||||
|
||||
onUpdate = evt => {
|
||||
evt.preventDefault();
|
||||
this.props.team.update();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="page-sub-heading">Team Settings</h3>
|
||||
<form name="teamDetailsForm" className="gf-form-group">
|
||||
<div className="gf-form max-width-30">
|
||||
<Label>Name</Label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={this.props.team.name}
|
||||
className="gf-form-input max-width-22"
|
||||
onChange={this.onChangeName}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form max-width-30">
|
||||
<Label tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)">
|
||||
Email
|
||||
</Label>
|
||||
<input
|
||||
type="email"
|
||||
className="gf-form-input max-width-22"
|
||||
value={this.props.team.email}
|
||||
placeholder="team@email.com"
|
||||
onChange={this.onChangeEmail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form-button-row">
|
||||
<button type="submit" className="btn btn-success" onClick={this.onUpdate}>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(TeamSettings);
|
@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
|
||||
import LoginBackground from './components/Login/LoginBackground';
|
||||
import { SearchResult } from './components/search/SearchResult';
|
||||
import { TagFilter } from './components/TagFilter/TagFilter';
|
||||
import UserPicker from './components/Picker/UserPicker';
|
||||
import DashboardPermissions from './components/Permissions/DashboardPermissions';
|
||||
|
||||
export function registerAngularDirectives() {
|
||||
@ -19,6 +18,5 @@ export function registerAngularDirectives() {
|
||||
['onSelect', { watchDepth: 'reference' }],
|
||||
['tagOptions', { watchDepth: 'reference' }],
|
||||
]);
|
||||
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
|
||||
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
|
||||
}
|
||||
|
21
public/app/core/components/Forms/Forms.tsx
Normal file
21
public/app/core/components/Forms/Forms.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React, { SFC, ReactNode } from 'react';
|
||||
import Tooltip from '../Tooltip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
tooltip?: string;
|
||||
for?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Label: SFC<Props> = props => {
|
||||
return (
|
||||
<span className="gf-form-label width-10">
|
||||
<span>{props.children}</span>
|
||||
{props.tooltip && (
|
||||
<Tooltip className="gf-form-help-icon--right-normal" placement="auto" content="hello">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -1,32 +1,32 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import AddPermissions from './AddPermissions';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
jest.mock('app/core/services/backend_srv', () => ({
|
||||
getBackendSrv: () => {
|
||||
return {
|
||||
get: () => {
|
||||
return Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
|
||||
]);
|
||||
},
|
||||
post: jest.fn(() => Promise.resolve({})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AddPermissions', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
let instance;
|
||||
let backendSrv: any = getBackendSrv();
|
||||
|
||||
beforeAll(() => {
|
||||
backendSrv.get.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
|
||||
store = RootStore.create({}, { backendSrv: backendSrv });
|
||||
wrapper = shallow(<AddPermissions permissions={store.permissions} />);
|
||||
instance = wrapper.instance();
|
||||
return store.permissions.load(1, true, false);
|
||||
});
|
||||
@ -43,8 +43,8 @@ describe('AddPermissions', () => {
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.userPicked(userItem);
|
||||
instance.onTypeChanged(evt);
|
||||
instance.onUserSelected(userItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
@ -70,8 +70,8 @@ describe('AddPermissions', () => {
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.teamPicked(teamItem);
|
||||
instance.onTypeChanged(evt);
|
||||
instance.onTeamSelected(teamItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
|
@ -1,24 +1,19 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
|
||||
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
|
||||
import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
export interface IProps {
|
||||
export interface Props {
|
||||
permissions: any;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
class AddPermissions extends Component<IProps, any> {
|
||||
class AddPermissions extends Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.teamPicked = this.teamPicked.bind(this);
|
||||
this.permissionPicked = this.permissionPicked.bind(this);
|
||||
this.typeChanged = this.typeChanged.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
@ -26,49 +21,49 @@ class AddPermissions extends Component<IProps, any> {
|
||||
permissions.resetNewType();
|
||||
}
|
||||
|
||||
typeChanged(evt) {
|
||||
onTypeChanged = evt => {
|
||||
const { value } = evt.target;
|
||||
const { permissions } = this.props;
|
||||
|
||||
permissions.setNewType(value);
|
||||
}
|
||||
};
|
||||
|
||||
userPicked(user: User) {
|
||||
onUserSelected = (user: User) => {
|
||||
const { permissions } = this.props;
|
||||
if (!user) {
|
||||
permissions.newItem.setUser(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
|
||||
}
|
||||
};
|
||||
|
||||
teamPicked(team: Team) {
|
||||
onTeamSelected = (team: Team) => {
|
||||
const { permissions } = this.props;
|
||||
if (!team) {
|
||||
permissions.newItem.setTeam(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
|
||||
}
|
||||
};
|
||||
|
||||
permissionPicked(permission: OptionWithDescription) {
|
||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||
const { permissions } = this.props;
|
||||
return permissions.newItem.setPermission(permission.value);
|
||||
}
|
||||
};
|
||||
|
||||
resetNewType() {
|
||||
const { permissions } = this.props;
|
||||
return permissions.resetNewType();
|
||||
}
|
||||
|
||||
handleSubmit(evt) {
|
||||
onSubmit = evt => {
|
||||
evt.preventDefault();
|
||||
const { permissions } = this.props;
|
||||
permissions.addStoreItem();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { permissions, backendSrv } = this.props;
|
||||
const { permissions } = this.props;
|
||||
const newItem = permissions.newItem;
|
||||
const pickerClassName = 'width-20';
|
||||
|
||||
@ -79,12 +74,12 @@ class AddPermissions extends Component<IProps, any> {
|
||||
<button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<form name="addPermission" onSubmit={this.handleSubmit}>
|
||||
<h6>Add Permission For</h6>
|
||||
<form name="addPermission" onSubmit={this.onSubmit}>
|
||||
<h5>Add Permission For</h5>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<div className="gf-form-select-wrapper">
|
||||
<select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
|
||||
<select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.onTypeChanged}>
|
||||
{aclTypes.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
@ -98,30 +93,20 @@ class AddPermissions extends Component<IProps, any> {
|
||||
|
||||
{newItem.type === 'User' ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.userPicked}
|
||||
value={newItem.userId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<UserPicker onSelected={this.onUserSelected} value={newItem.userId} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newItem.type === 'Group' ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.teamPicked}
|
||||
value={newItem.teamId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<TeamPicker onSelected={this.onTeamSelected} value={newItem.teamId} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={this.permissionPicked}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={newItem.permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
|
@ -8,13 +8,14 @@ import AddPermissions from 'app/core/components/Permissions/AddPermissions';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface IProps {
|
||||
export interface Props {
|
||||
dashboardId: number;
|
||||
folder?: FolderInfo;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
class DashboardPermissions extends Component<IProps, any> {
|
||||
class DashboardPermissions extends Component<Props, any> {
|
||||
permissions: any;
|
||||
|
||||
constructor(props) {
|
||||
@ -53,7 +54,7 @@ class DashboardPermissions extends Component<IProps, any> {
|
||||
</div>
|
||||
</div>
|
||||
<SlideDown in={this.permissions.isAddPermissionsVisible}>
|
||||
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
|
||||
<AddPermissions permissions={this.permissions} />
|
||||
</SlideDown>
|
||||
<Permissions
|
||||
permissions={this.permissions}
|
||||
|
@ -25,7 +25,7 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={() => {}}
|
||||
onSelected={() => {}}
|
||||
value={item.permission}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
|
@ -68,7 +68,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={handleChangePermission}
|
||||
onSelected={handleChangePermission}
|
||||
value={item.permission}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
|
@ -2,9 +2,9 @@ import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
|
||||
export interface IProps {
|
||||
export interface Props {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
handlePicked: (permission) => void;
|
||||
onSelected: (permission) => void;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
@ -16,14 +16,14 @@ export interface OptionWithDescription {
|
||||
description: string;
|
||||
}
|
||||
|
||||
class DescriptionPicker extends Component<IProps, any> {
|
||||
class DescriptionPicker extends Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
|
||||
const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
|
||||
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
@ -34,7 +34,7 @@ class DescriptionPicker extends Component<IProps, any> {
|
||||
clearable={false}
|
||||
labelKey="label"
|
||||
options={optionsWithDesc}
|
||||
onChange={handlePicked}
|
||||
onChange={onSelected}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={DescriptionOption}
|
||||
placeholder="Choose"
|
||||
|
@ -1,19 +1,23 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import TeamPicker from './TeamPicker';
|
||||
import { TeamPicker } from './TeamPicker';
|
||||
|
||||
const model = {
|
||||
backendSrv: {
|
||||
get: () => {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
jest.mock('app/core/services/backend_srv', () => ({
|
||||
getBackendSrv: () => {
|
||||
return {
|
||||
get: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
handlePicked: () => {},
|
||||
};
|
||||
}));
|
||||
|
||||
describe('TeamPicker', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<TeamPicker {...model} />).toJSON();
|
||||
const props = {
|
||||
onSelected: () => {},
|
||||
};
|
||||
const tree = renderer.create(<TeamPicker {...props} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,19 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
export interface Props {
|
||||
onSelected: (team: Team) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
label: string;
|
||||
@ -20,13 +21,12 @@ export interface Team {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
class TeamPicker extends Component<IProps, any> {
|
||||
export class TeamPicker extends Component<Props, State> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.state = { isLoading: false };
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
@ -36,9 +36,9 @@ class TeamPicker extends Component<IProps, any> {
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
const backendSrv = getBackendSrv();
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
@ -49,18 +49,18 @@ class TeamPicker extends Component<IProps, any> {
|
||||
};
|
||||
});
|
||||
|
||||
toggleLoading(false);
|
||||
this.setState({ isLoading: false });
|
||||
return { options: teams };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked, value, className } = this.props;
|
||||
const { onSelected, value, className } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
@ -69,10 +69,10 @@ class TeamPicker extends Component<IProps, any> {
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No teams found"
|
||||
onChange={handlePicked}
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
placeholder="Select a team"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
@ -80,5 +80,3 @@ class TeamPicker extends Component<IProps, any> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(TeamPicker);
|
||||
|
@ -1,19 +1,20 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import UserPicker from './UserPicker';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
const model = {
|
||||
backendSrv: {
|
||||
get: () => {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
jest.mock('app/core/services/backend_srv', () => ({
|
||||
getBackendSrv: () => {
|
||||
return {
|
||||
get: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
handlePicked: () => {},
|
||||
};
|
||||
}));
|
||||
|
||||
describe('UserPicker', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<UserPicker {...model} />).toJSON();
|
||||
const tree = renderer.create(<UserPicker onSelected={() => {}} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,19 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
export interface Props {
|
||||
onSelected: (user: User) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
@ -20,13 +21,12 @@ export interface User {
|
||||
login: string;
|
||||
}
|
||||
|
||||
class UserPicker extends Component<IProps, any> {
|
||||
export class UserPicker extends Component<Props, State> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.state = { isLoading: false };
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
@ -36,29 +36,34 @@ class UserPicker extends Component<IProps, any> {
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
const backendSrv = getBackendSrv();
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
|
||||
const users = result.map(user => {
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return backendSrv
|
||||
.get(`/api/org/users?query=${query}&limit=10`)
|
||||
.then(result => {
|
||||
return {
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
options: result.map(user => ({
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
toggleLoading(false);
|
||||
return { options: users };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked, value, className } = this.props;
|
||||
const { value, className } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
@ -67,10 +72,10 @@ class UserPicker extends Component<IProps, any> {
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={handlePicked}
|
||||
onChange={this.props.onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
placeholder="Select user"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
@ -78,5 +83,3 @@ class UserPicker extends Component<IProps, any> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(UserPicker);
|
||||
|
@ -1,34 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
handlePicked: (data) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function withPicker(WrappedComponent) {
|
||||
return class WithPicker extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleLoading = this.toggleLoading.bind(this);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggleLoading(isLoading) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
|
||||
}
|
||||
};
|
||||
}
|
@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events';
|
||||
import Drop from 'tether-drop';
|
||||
import { createStore } from 'app/stores/store';
|
||||
import colors from 'app/core/utils/colors';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
export class GrafanaCtrl {
|
||||
@ -24,6 +24,8 @@ export class GrafanaCtrl {
|
||||
backendSrv: BackendSrv,
|
||||
datasourceSrv: DatasourceSrv
|
||||
) {
|
||||
// sets singleston instances for angular services so react components can access them
|
||||
setBackendSrv(backendSrv);
|
||||
createStore({ backendSrv, datasourceSrv });
|
||||
|
||||
$scope.init = function() {
|
||||
|
@ -29,11 +29,13 @@ export function pageScrollbar() {
|
||||
scope.$on('$routeChangeSuccess', () => {
|
||||
lastPos = 0;
|
||||
elem[0].scrollTop = 0;
|
||||
elem[0].focus();
|
||||
// Focus page to enable scrolling by keyboard
|
||||
elem[0].focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
elem[0].tabIndex = -1;
|
||||
elem[0].focus();
|
||||
// Focus page to enable scrolling by keyboard
|
||||
elem[0].focus({ preventScroll: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,64 +0,0 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
const template = `
|
||||
<div class="dropdown">
|
||||
<gf-form-dropdown model="ctrl.group"
|
||||
get-options="ctrl.debouncedSearchGroups($query)"
|
||||
css-class="gf-size-auto"
|
||||
on-change="ctrl.onChange($option)"
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
`;
|
||||
export class TeamPickerCtrl {
|
||||
group: any;
|
||||
teamPicked: any;
|
||||
debouncedSearchGroups: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv) {
|
||||
this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.group = { text: 'Choose', value: null };
|
||||
}
|
||||
|
||||
searchGroups(query: string) {
|
||||
return Promise.resolve(
|
||||
this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => {
|
||||
return _.map(result.teams, ug => {
|
||||
return { text: ug.name, value: ug };
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onChange(option) {
|
||||
this.teamPicked({ $group: option.value });
|
||||
}
|
||||
}
|
||||
|
||||
export function teamPicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: TeamPickerCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
teamPicked: '&',
|
||||
},
|
||||
link: function(scope, elem, attrs, ctrl) {
|
||||
scope.$on('team-picker-reset', () => {
|
||||
ctrl.reset();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('teamPicker', teamPicker);
|
@ -1,71 +0,0 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
const template = `
|
||||
<div class="dropdown">
|
||||
<gf-form-dropdown model="ctrl.user"
|
||||
get-options="ctrl.debouncedSearchUsers($query)"
|
||||
css-class="gf-size-auto"
|
||||
on-change="ctrl.onChange($option)"
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
`;
|
||||
export class UserPickerCtrl {
|
||||
user: any;
|
||||
debouncedSearchUsers: any;
|
||||
userPicked: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv) {
|
||||
this.reset();
|
||||
this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
}
|
||||
|
||||
searchUsers(query: string) {
|
||||
return Promise.resolve(
|
||||
this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => {
|
||||
return _.map(result.users, user => {
|
||||
return { text: user.login + ' - ' + user.email, value: user };
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onChange(option) {
|
||||
this.userPicked({ $user: option.value });
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.user = { text: 'Choose', value: null };
|
||||
}
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function userPicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: UserPickerCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
userPicked: '&',
|
||||
},
|
||||
link: function(scope, elem, attrs, ctrl) {
|
||||
scope.$on('user-picker-reset', () => {
|
||||
ctrl.reset();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('userPicker', userPicker);
|
@ -45,8 +45,6 @@ import { KeybindingSrv } from './services/keybindingSrv';
|
||||
import { helpModal } from './components/help/help';
|
||||
import { JsonExplorer } from './components/json_explorer/json_explorer';
|
||||
import { NavModelSrv, NavModel } from './nav_model_srv';
|
||||
import { userPicker } from './components/user_picker';
|
||||
import { teamPicker } from './components/team_picker';
|
||||
import { geminiScrollbar } from './components/scroll/scroll';
|
||||
import { pageScrollbar } from './components/scroll/page_scroll';
|
||||
import { gfPageDirective } from './components/gf_page';
|
||||
@ -85,8 +83,6 @@ export {
|
||||
JsonExplorer,
|
||||
NavModelSrv,
|
||||
NavModel,
|
||||
userPicker,
|
||||
teamPicker,
|
||||
geminiScrollbar,
|
||||
pageScrollbar,
|
||||
gfPageDirective,
|
||||
|
29
public/app/core/logs_model.ts
Normal file
29
public/app/core/logs_model.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export enum LogLevel {
|
||||
crit = 'crit',
|
||||
warn = 'warn',
|
||||
err = 'error',
|
||||
error = 'error',
|
||||
info = 'info',
|
||||
debug = 'debug',
|
||||
trace = 'trace',
|
||||
}
|
||||
|
||||
export interface LogSearchMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
key: string;
|
||||
entry: string;
|
||||
logLevel: LogLevel;
|
||||
timestamp: string;
|
||||
timeFromNow: string;
|
||||
timeLocal: string;
|
||||
searchMatches?: LogSearchMatch[];
|
||||
}
|
||||
|
||||
export interface LogsModel {
|
||||
rows: LogRow[];
|
||||
}
|
@ -368,3 +368,17 @@ export class BackendSrv {
|
||||
}
|
||||
|
||||
coreModule.service('backendSrv', BackendSrv);
|
||||
|
||||
//
|
||||
// Code below is to expore the service to react components
|
||||
//
|
||||
|
||||
let singletonInstance: BackendSrv;
|
||||
|
||||
export function setBackendSrv(instance: BackendSrv) {
|
||||
singletonInstance = instance;
|
||||
}
|
||||
|
||||
export function getBackendSrv(): BackendSrv {
|
||||
return singletonInstance;
|
||||
}
|
||||
|
@ -191,7 +191,7 @@ export class KeybindingSrv {
|
||||
range,
|
||||
};
|
||||
const exploreState = encodePathComponent(JSON.stringify(state));
|
||||
this.$location.url(`/explore/${exploreState}`);
|
||||
this.$location.url(`/explore?state=${exploreState}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -449,6 +449,7 @@ kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
|
||||
kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
|
||||
kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk');
|
||||
kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF');
|
||||
kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł');
|
||||
|
||||
// Data (Binary)
|
||||
kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
|
||||
@ -880,6 +881,7 @@ kbn.getUnitFormats = function() {
|
||||
{ text: 'Swedish Krona (kr)', value: 'currencySEK' },
|
||||
{ text: 'Czech koruna (czk)', value: 'currencyCZK' },
|
||||
{ text: 'Swiss franc (CHF)', value: 'currencyCHF' },
|
||||
{ text: 'Polish Złoty (PLN)', value: 'currencyPLN' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -957,7 +959,7 @@ kbn.getUnitFormats = function() {
|
||||
text: 'throughput',
|
||||
submenu: [
|
||||
{ text: 'ops/sec (ops)', value: 'ops' },
|
||||
{ text: 'requets/sec (rps)', value: 'reqps' },
|
||||
{ text: 'requests/sec (rps)', value: 'reqps' },
|
||||
{ text: 'reads/sec (rps)', value: 'rps' },
|
||||
{ text: 'writes/sec (wps)', value: 'wps' },
|
||||
{ text: 'I/O ops/sec (iops)', value: 'iops' },
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div>
|
||||
<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
|
||||
<i class="gicon gicon-dashboard"></i>
|
||||
{{ctrl.dashboard.title}}
|
||||
<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user