Merge remote-tracking branch 'upstream/master' into postgres-query-builder

This commit is contained in:
Sven Klemm 2018-07-27 11:52:38 +02:00
commit 734118de86
219 changed files with 11347 additions and 5078 deletions

View File

@ -246,7 +246,7 @@ workflows:
test-and-build: test-and-build:
jobs: jobs:
- build-all: - build-all:
filters: *filter-not-release filters: *filter-only-master
- build-enterprise: - build-enterprise:
filters: *filter-only-master filters: *filter-only-master
- codespell: - codespell:
@ -270,9 +270,7 @@ workflows:
- gometalinter - gometalinter
- mysql-integration-test - mysql-integration-test
- postgres-integration-test - postgres-integration-test
filters: filters: *filter-only-master
branches:
only: master
- deploy-enterprise-master: - deploy-enterprise-master:
requires: requires:
- build-all - build-all

View File

@ -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) * **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) * **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 ### 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) * **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**: 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**: 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) * **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) * **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) * **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) * **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) * **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 ### Minor
* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) * **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) * **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) # 5.2.1 (2018-06-29)

15
Gopkg.lock generated
View File

@ -32,6 +32,7 @@
"aws/credentials/ec2rolecreds", "aws/credentials/ec2rolecreds",
"aws/credentials/endpointcreds", "aws/credentials/endpointcreds",
"aws/credentials/stscreds", "aws/credentials/stscreds",
"aws/csm",
"aws/defaults", "aws/defaults",
"aws/ec2metadata", "aws/ec2metadata",
"aws/endpoints", "aws/endpoints",
@ -43,6 +44,8 @@
"internal/shareddefaults", "internal/shareddefaults",
"private/protocol", "private/protocol",
"private/protocol/ec2query", "private/protocol/ec2query",
"private/protocol/eventstream",
"private/protocol/eventstream/eventstreamapi",
"private/protocol/query", "private/protocol/query",
"private/protocol/query/queryutil", "private/protocol/query/queryutil",
"private/protocol/rest", "private/protocol/rest",
@ -54,8 +57,8 @@
"service/s3", "service/s3",
"service/sts" "service/sts"
] ]
revision = "c7cd1ebe87257cde9b65112fc876b0339ea0ac30" revision = "fde4ded7becdeae4d26bf1212916aabba79349b4"
version = "v1.13.49" version = "v1.14.12"
[[projects]] [[projects]]
branch = "master" branch = "master"
@ -424,6 +427,12 @@
revision = "1744e2970ca51c86172c8190fadad617561ed6e7" revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
version = "v1.0.0" version = "v1.0.0"
[[projects]]
branch = "master"
name = "github.com/shurcooL/sanitized_anchor_name"
packages = ["."]
revision = "86672fcb3f950f35f2e675df2240550f2a50762f"
[[projects]] [[projects]]
name = "github.com/smartystreets/assertions" name = "github.com/smartystreets/assertions"
packages = [ packages = [
@ -670,6 +679,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "85cc057e0cc074ab5b43bd620772d63d51e07b04e8782fcfe55e6929d2fc40f7" inputs-digest = "cb8e7fd81f23ec987fc4d5dd9d31ae0f1164bc2f30cbea2fe86e0d97dd945beb"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -36,7 +36,7 @@ ignored = [
[[constraint]] [[constraint]]
name = "github.com/aws/aws-sdk-go" name = "github.com/aws/aws-sdk-go"
version = "1.12.65" version = "1.13.56"
[[constraint]] [[constraint]]
branch = "master" branch = "master"

View File

@ -330,6 +330,7 @@ func createPackage(options linuxPackageOptions) {
name := "grafana" name := "grafana"
if enterprise { if enterprise {
name += "-enterprise" name += "-enterprise"
args = append(args, "--replaces", "grafana")
} }
args = append(args, "--name", name) args = append(args, "--name", name)

View File

@ -72,6 +72,8 @@ email = "email"
[[servers.group_mappings]] [[servers.group_mappings]]
group_dn = "cn=admins,dc=grafana,dc=org" group_dn = "cn=admins,dc=grafana,dc=org"
org_role = "Admin" 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 # The Grafana organization database id, optional, if left out the default org (id 1) will be used
# org_id = 1 # org_id = 1

View File

@ -1,11 +1,16 @@
This folder contains useful scripts and configuration for... This folder contains useful scripts and configuration for...
* Configuring datasources in Grafana * Configuring dev datasources in Grafana
* Provision example dashboards in Grafana * Configuring dev & test scenarios dashboards.
* Run preconfiured datasources as docker containers
want to know more? run setup!
```bash ```bash
./setup.sh ./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.

View File

@ -14,6 +14,9 @@ datasources:
isDefault: true isDefault: true
url: http://localhost:9090 url: http://localhost:9090
- name: gdev-testdata
type: testdata
- name: gdev-influxdb - name: gdev-influxdb
type: influxdb type: influxdb
access: proxy access: proxy
@ -60,7 +63,8 @@ datasources:
url: localhost:5432 url: localhost:5432
database: grafana database: grafana
user: grafana user: grafana
password: password secureJsonData:
password: password
jsonData: jsonData:
sslmode: "disable" sslmode: "disable"
@ -71,3 +75,4 @@ datasources:
authType: credentials authType: credentials
defaultRegion: eu-west-2 defaultRegion: eu-west-2

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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
}

View File

@ -1,6 +1,6 @@
{ {
"revision": 2, "revision": 2,
"title": "TestData - Alerts", "title": "Alerting with TestData",
"tags": [ "tags": [
"grafana-test" "grafana-test"
], ],
@ -48,7 +48,7 @@
}, },
"aliasColors": {}, "aliasColors": {},
"bars": false, "bars": false,
"datasource": "Grafana TestData", "datasource": "gdev-testdata",
"editable": true, "editable": true,
"error": false, "error": false,
"fill": 1, "fill": 1,
@ -161,7 +161,7 @@
}, },
"aliasColors": {}, "aliasColors": {},
"bars": false, "bars": false,
"datasource": "Grafana TestData", "datasource": "gdev-testdata",
"editable": true, "editable": true,
"error": false, "error": false,
"fill": 1, "fill": 1,

View File

@ -1,4 +1,4 @@
#/bin/bash #!/bin/bash
bulkDashboard() { bulkDashboard() {
@ -22,31 +22,37 @@ requiresJsonnet() {
fi 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 ln -s -f ../../../devenv/dashboards.yaml ../conf/provisioning/dashboards/dev.yaml
} }
defaultDatasources() { devDatasources() {
echo "setting up all default datasources using provisioning" echo -e "\xE2\x9C\x94 Setting up all dev datasources using provisioning"
ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml
} }
usage() { usage() {
echo -e "install.sh\n\tThis script setups dev provision for datasources and dashboards" echo -e "\n"
echo "Usage:" echo "Usage:"
echo " bulk-dashboards - create and provisioning 400 dashboards" echo " bulk-dashboards - create and provisioning 400 dashboards"
echo " no args - provisiong core datasources and dev dashboards" echo " no args - provisiong core datasources and dev dashboards"
} }
main() { main() {
echo -e "------------------------------------------------------------------"
echo -e "This script setups provisioning for dev datasources and dashboards"
echo -e "------------------------------------------------------------------"
echo -e "\n"
local cmd=$1 local cmd=$1
if [[ $cmd == "bulk-dashboards" ]]; then if [[ $cmd == "bulk-dashboards" ]]; then
bulkDashboard bulkDashboard
else else
defaultDashboards devDashboards
defaultDatasources devDatasources
fi fi
if [[ -z "$cmd" ]]; then if [[ -z "$cmd" ]]; then

View File

@ -1,3 +1,4 @@
FROM nginx:alpine FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY htpasswd /etc/nginx/htpasswd

View File

@ -0,0 +1,3 @@
user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0
user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9.
admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1

View File

@ -13,7 +13,26 @@ http {
listen 10080; listen 10080;
location /grafana/ { 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/; proxy_pass http://localhost:3000/;
} }
} }
} }

View 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"

View File

@ -14,12 +14,12 @@ After adding ldif files to `prepopulate`:
## Enabling LDAP in Grafana ## 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 ```ini
[auth.ldap] [auth.ldap]
enabled = true enabled = true
config_file = conf/ldap.toml config_file = conf/ldap_dev.toml
; allow_sign_up = true ; allow_sign_up = true
``` ```
@ -43,6 +43,3 @@ editors
no groups no groups
ldap-viewer ldap-viewer

View File

@ -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). 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 ### Using variables in queries
There are two syntaxes: There are two syntaxes:

View 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
{}
```

View File

@ -15,6 +15,8 @@ weight = 1
The Grafana back-end has a number of configuration options that can be The Grafana back-end has a number of configuration options that can be
specified in a `.ini` configuration file or specified using environment variables. 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 ## Comments In .ini Files
Semicolons (the `;` char) are the standard way to comment out lines in a `.ini` file. 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 (id 1). When set to `false`, new users will automatically cause a new
organization to be created for that new user. 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 ### auto_assign_org_role
The role new users will be assigned for the main organization (if the 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 Url to where Grafana will send PUT request with images
### public_url ### 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 ### username
basic auth username basic auth username

View File

@ -23,8 +23,9 @@ specific configuration file (default: `/etc/grafana/ldap.toml`).
### Example config ### Example config
```toml ```toml
# Set to true to log user information returned from LDAP # To troubleshoot and get more log info enable ldap debug logging in grafana.ini
verbose_logging = false # [log]
# filters = ldap:debug
[[servers]] [[servers]]
# Ldap server host (specify multiple hosts space separated) # Ldap server host (specify multiple hosts space separated)
@ -73,6 +74,8 @@ email = "email"
[[servers.group_mappings]] [[servers.group_mappings]]
group_dn = "cn=admins,dc=grafana,dc=org" group_dn = "cn=admins,dc=grafana,dc=org"
org_role = "Admin" 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 # 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 # 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 change the LDAP groups of a user, the change will take effect the next
time the user logs in. 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 ### 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. 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.

View File

@ -11,7 +11,7 @@ weight = 1
# Variables # Variables
Variables allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application 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. 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" >}} {{< 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. 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 ## Repeating Panels
Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want

View File

@ -34,7 +34,7 @@
"expose-loader": "^0.7.3", "expose-loader": "^0.7.3",
"extract-text-webpack-plugin": "^4.0.0-beta.0", "extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^1.1.11", "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", "gaze": "^1.1.2",
"glob": "~7.0.0", "glob": "~7.0.0",
"grunt": "1.0.1", "grunt": "1.0.1",
@ -71,12 +71,14 @@
"karma-webpack": "^3.0.0", "karma-webpack": "^3.0.0",
"lint-staged": "^6.0.0", "lint-staged": "^6.0.0",
"load-grunt-tasks": "3.5.2", "load-grunt-tasks": "3.5.2",
"mini-css-extract-plugin": "^0.4.0",
"mobx-react-devtools": "^4.2.15", "mobx-react-devtools": "^4.2.15",
"mocha": "^4.0.1", "mocha": "^4.0.1",
"ng-annotate-loader": "^0.6.1", "ng-annotate-loader": "^0.6.1",
"ng-annotate-webpack-plugin": "^0.2.1-pre", "ng-annotate-webpack-plugin": "^0.2.1-pre",
"ngtemplate-loader": "^2.0.1", "ngtemplate-loader": "^2.0.1",
"npm": "^5.4.2", "npm": "^5.4.2",
"optimize-css-assets-webpack-plugin": "^4.0.2",
"phantomjs-prebuilt": "^2.1.15", "phantomjs-prebuilt": "^2.1.15",
"postcss-browser-reporter": "^0.5.0", "postcss-browser-reporter": "^0.5.0",
"postcss-loader": "^2.0.6", "postcss-loader": "^2.0.6",
@ -90,15 +92,16 @@
"style-loader": "^0.21.0", "style-loader": "^0.21.0",
"systemjs": "0.20.19", "systemjs": "0.20.19",
"systemjs-plugin-css": "^0.1.36", "systemjs-plugin-css": "^0.1.36",
"ts-loader": "^4.3.0",
"ts-jest": "^22.4.6", "ts-jest": "^22.4.6",
"ts-loader": "^4.3.0",
"tslib": "^1.9.3",
"tslint": "^5.8.0", "tslint": "^5.8.0",
"tslint-loader": "^3.5.3", "tslint-loader": "^3.5.3",
"typescript": "^2.6.2", "typescript": "^2.6.2",
"uglifyjs-webpack-plugin": "^1.2.7",
"webpack": "^4.8.0", "webpack": "^4.8.0",
"webpack-bundle-analyzer": "^2.9.0", "webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1", "webpack-cleanup-plugin": "^0.5.1",
"fork-ts-checker-webpack-plugin": "^0.4.2",
"webpack-cli": "^2.1.4", "webpack-cli": "^2.1.4",
"webpack-dev-server": "^3.1.0", "webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.0", "webpack-merge": "^4.1.0",
@ -155,14 +158,12 @@
"immutable": "^3.8.2", "immutable": "^3.8.2",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"mini-css-extract-plugin": "^0.4.0",
"mobx": "^3.4.1", "mobx": "^3.4.1",
"mobx-react": "^4.3.5", "mobx-react": "^4.3.5",
"mobx-state-tree": "^1.3.1", "mobx-state-tree": "^1.3.1",
"moment": "^2.22.2", "moment": "^2.22.2",
"mousetrap": "^1.6.0", "mousetrap": "^1.6.0",
"mousetrap-global-bind": "^1.1.0", "mousetrap-global-bind": "^1.1.0",
"optimize-css-assets-webpack-plugin": "^4.0.2",
"prismjs": "^1.6.0", "prismjs": "^1.6.0",
"prop-types": "^15.6.0", "prop-types": "^15.6.0",
"react": "^16.2.0", "react": "^16.2.0",
@ -181,10 +182,9 @@
"slate-react": "^0.12.4", "slate-react": "^0.12.4",
"tether": "^1.4.0", "tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop/tarball/master", "tether-drop": "https://github.com/torkelo/drop/tarball/master",
"tinycolor2": "^1.4.1", "tinycolor2": "^1.4.1"
"uglifyjs-webpack-plugin": "^1.2.7"
}, },
"resolutions": { "resolutions": {
"caniuse-db": "1.0.30000772" "caniuse-db": "1.0.30000772"
} }
} }

View File

@ -31,7 +31,7 @@ func TestAlertingApiEndpoint(t *testing.T) {
}) })
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{} query.Result = []*m.TeamDTO{}
return nil return nil
}) })

View File

@ -119,7 +119,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
}) })
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{} query.Result = []*m.TeamDTO{}
return nil return nil
}) })

View File

@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/", reqSignedIn, Index) r.Get("/dashboards/", reqSignedIn, Index)
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)
r.Get("/playlists/*", reqSignedIn, Index) r.Get("/playlists/*", reqSignedIn, Index)

View File

@ -39,7 +39,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
return nil return nil
}) })
teamResp := []*m.Team{} teamResp := []*m.TeamDTO{}
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = teamResp query.Result = teamResp
return nil return nil

View File

@ -61,7 +61,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
}) })
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{} query.Result = []*m.TeamDTO{}
return nil return nil
}) })
@ -230,7 +230,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
}) })
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
query.Result = []*m.Team{} query.Result = []*m.TeamDTO{}
return nil return nil
}) })

View File

@ -52,7 +52,7 @@ func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response {
if res.Error != nil { if res.Error != nil {
res.ErrorString = res.Error.Error() res.ErrorString = res.Error.Error()
resp.Message = res.ErrorString resp.Message = res.ErrorString
statusCode = 500 statusCode = 400
} }
} }

View File

@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response {
func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response { func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response {
cmd.OrgId = c.OrgId cmd.OrgId = c.OrgId
cmd.Id = c.ParamsInt64(":id")
if err := bus.Dispatch(&cmd); err != nil { if err := bus.Dispatch(&cmd); err != nil {
return Error(500, "Failed to save playlist", err) return Error(500, "Failed to save playlist", err)

View File

@ -93,5 +93,6 @@ func GetTeamByID(c *m.ReqContext) Response {
return Error(500, "Failed to get Team", err) return Error(500, "Failed to get Team", err)
} }
query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
return JSON(200, &query.Result) return JSON(200, &query.Result)
} }

View File

@ -13,7 +13,7 @@ import (
func TestTeamApiEndpoint(t *testing.T) { func TestTeamApiEndpoint(t *testing.T) {
Convey("Given two teams", t, func() { Convey("Given two teams", t, func() {
mockResult := models.SearchTeamQueryResult{ mockResult := models.SearchTeamQueryResult{
Teams: []*models.SearchTeamDto{ Teams: []*models.TeamDTO{
{Name: "team1"}, {Name: "team1"},
{Name: "team2"}, {Name: "team2"},
}, },

View File

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strings"
"time" "time"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
@ -35,6 +36,16 @@ var netClient = &http.Client{
Transport: netTransport, 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) { func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
url, _ := url.Parse(u.url) url, _ := url.Parse(u.url)
filename := util.GetRandomString(20) + ".png" filename := util.GetRandomString(20) + ".png"
@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
} }
if u.public_url != "" { if u.public_url != "" {
publicURL, _ := url.Parse(u.public_url) return u.PublicURL(filename), nil
publicURL.Path = path.Join(publicURL.Path, filename)
return publicURL.String(), nil
} }
return url.String(), nil return url.String(), nil

View File

@ -2,6 +2,7 @@ package imguploader
import ( import (
"context" "context"
"net/url"
"testing" "testing"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) {
So(path, ShouldStartWith, "http://publicurl:8888/webdav/") 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")
})
}

View File

@ -72,6 +72,13 @@ func UpsertUser(cmd *m.UpsertUserCommand) error {
return err 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{ err = bus.Dispatch(&m.SyncTeamsCommand{
User: cmd.Result, User: cmd.Result,
ExternalUser: extUser, ExternalUser: extUser,

View File

@ -175,6 +175,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) { if ldapUser.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole extUser.OrgRoles[group.OrgId] = group.OrgRole
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
} }
} }
@ -190,18 +191,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
} }
// add/update user in grafana // add/update user in grafana
userQuery := &m.UpsertUserCommand{ upsertUserCmd := &m.UpsertUserCommand{
ReqContext: ctx, ReqContext: ctx,
ExternalUser: extUser, ExternalUser: extUser,
SignupAllowed: setting.LdapAllowSignup, SignupAllowed: setting.LdapAllowSignup,
} }
err := bus.Dispatch(userQuery) err := bus.Dispatch(upsertUserCmd)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return userQuery.Result, nil return upsertUserCmd.Result, nil
} }
func (a *ldapAuther) serverBind() error { func (a *ldapAuther) serverBind() error {

View File

@ -44,9 +44,10 @@ type LdapAttributeMap struct {
} }
type LdapGroupToOrgRole struct { type LdapGroupToOrgRole struct {
GroupDN string `toml:"group_dn"` GroupDN string `toml:"group_dn"`
OrgId int64 `toml:"org_id"` OrgId int64 `toml:"org_id"`
OrgRole m.RoleType `toml:"org_role"` 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 var LdapCfg LdapConfig

View File

@ -98,6 +98,10 @@ func TestLdapAuther(t *testing.T) {
So(result.Login, ShouldEqual, "torkelo") 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.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) 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() { Convey("When calling SyncUser", t, func() {
@ -332,6 +360,11 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
return nil 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 { bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error {
sc.getUserByAuthInfoQuery = cmd sc.getUserByAuthInfoQuery = cmd
sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login} sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login}
@ -379,14 +412,15 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
} }
type scenarioContext struct { type scenarioContext struct {
getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery
getUserOrgListQuery *m.GetUserOrgListQuery getUserOrgListQuery *m.GetUserOrgListQuery
createUserCmd *m.CreateUserCommand createUserCmd *m.CreateUserCommand
addOrgUserCmd *m.AddOrgUserCommand addOrgUserCmd *m.AddOrgUserCommand
updateOrgUserCmd *m.UpdateOrgUserCommand updateOrgUserCmd *m.UpdateOrgUserCommand
removeOrgUserCmd *m.RemoveOrgUserCommand removeOrgUserCmd *m.RemoveOrgUserCommand
updateUserCmd *m.UpdateUserCommand updateUserCmd *m.UpdateUserCommand
setUsingOrgCmd *m.SetUsingOrgCommand setUsingOrgCmd *m.SetUsingOrgCommand
updateUserPermissionsCmd *m.UpdateUserPermissionsCommand
} }
func (sc *scenarioContext) userQueryReturns(user *m.User) { func (sc *scenarioContext) userQueryReturns(user *m.User) {

View File

@ -44,6 +44,7 @@ var (
M_Alerting_Notification_Sent *prometheus.CounterVec M_Alerting_Notification_Sent *prometheus.CounterVec
M_Aws_CloudWatch_GetMetricStatistics prometheus.Counter M_Aws_CloudWatch_GetMetricStatistics prometheus.Counter
M_Aws_CloudWatch_ListMetrics prometheus.Counter M_Aws_CloudWatch_ListMetrics prometheus.Counter
M_Aws_CloudWatch_GetMetricData prometheus.Counter
M_DB_DataSource_QueryById prometheus.Counter M_DB_DataSource_QueryById prometheus.Counter
// Timers // Timers
@ -218,6 +219,12 @@ func init() {
Namespace: exporterName, 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{ M_DB_DataSource_QueryById = prometheus.NewCounter(prometheus.CounterOpts{
Name: "db_datasource_query_by_id_total", Name: "db_datasource_query_by_id_total",
Help: "counter for getting datasource by id", Help: "counter for getting datasource by id",
@ -307,6 +314,7 @@ func initMetricVars() {
M_Alerting_Notification_Sent, M_Alerting_Notification_Sent,
M_Aws_CloudWatch_GetMetricStatistics, M_Aws_CloudWatch_GetMetricStatistics,
M_Aws_CloudWatch_ListMetrics, M_Aws_CloudWatch_ListMetrics,
M_Aws_CloudWatch_GetMetricData,
M_DB_DataSource_QueryById, M_DB_DataSource_QueryById,
M_Alerting_Active_Alerts, M_Alerting_Active_Alerts,
M_StatTotal_Dashboards, M_StatTotal_Dashboards,

View File

@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard
type UpdatePlaylistCommand struct { type UpdatePlaylistCommand struct {
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Id int64 `json:"id" binding:"Required"` Id int64 `json:"id"`
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
Interval string `json:"interval"` Interval string `json:"interval"`
Items []PlaylistItemDTO `json:"items"` Items []PlaylistItemDTO `json:"items"`

View File

@ -49,13 +49,13 @@ type DeleteTeamCommand struct {
type GetTeamByIdQuery struct { type GetTeamByIdQuery struct {
OrgId int64 OrgId int64
Id int64 Id int64
Result *Team Result *TeamDTO
} }
type GetTeamsByUserQuery struct { type GetTeamsByUserQuery struct {
OrgId int64 OrgId int64
UserId int64 `json:"userId"` UserId int64 `json:"userId"`
Result []*Team `json:"teams"` Result []*TeamDTO `json:"teams"`
} }
type SearchTeamsQuery struct { type SearchTeamsQuery struct {
@ -68,7 +68,7 @@ type SearchTeamsQuery struct {
Result SearchTeamQueryResult Result SearchTeamQueryResult
} }
type SearchTeamDto struct { type TeamDTO struct {
Id int64 `json:"id"` Id int64 `json:"id"`
OrgId int64 `json:"orgId"` OrgId int64 `json:"orgId"`
Name string `json:"name"` Name string `json:"name"`
@ -78,8 +78,8 @@ type SearchTeamDto struct {
} }
type SearchTeamQueryResult struct { type SearchTeamQueryResult struct {
TotalCount int64 `json:"totalCount"` TotalCount int64 `json:"totalCount"`
Teams []*SearchTeamDto `json:"teams"` Teams []*TeamDTO `json:"teams"`
Page int `json:"page"` Page int `json:"page"`
PerPage int `json:"perPage"` PerPage int `json:"perPage"`
} }

View File

@ -13,14 +13,15 @@ type UserAuth struct {
} }
type ExternalUserInfo struct { type ExternalUserInfo struct {
AuthModule string AuthModule string
AuthId string AuthId string
UserId int64 UserId int64
Email string Email string
Login string Login string
Name string Name string
Groups []string Groups []string
OrgRoles map[int64]RoleType OrgRoles map[int64]RoleType
IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync)
} }
// --------------------- // ---------------------

View File

@ -17,11 +17,14 @@ import (
plugin "github.com/hashicorp/go-plugin" plugin "github.com/hashicorp/go-plugin"
) )
// DataSourcePlugin contains all metadata about a datasource plugin
type DataSourcePlugin struct { type DataSourcePlugin struct {
FrontendPluginBase FrontendPluginBase
Annotations bool `json:"annotations"` Annotations bool `json:"annotations"`
Metrics bool `json:"metrics"` Metrics bool `json:"metrics"`
Alerting bool `json:"alerting"` Alerting bool `json:"alerting"`
Explore bool `json:"explore"`
Logs bool `json:"logs"`
QueryOptions map[string]bool `json:"queryOptions,omitempty"` QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"` BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"` Mixed bool `json:"mixed,omitempty"`

View File

@ -30,7 +30,7 @@ type dashboardGuardianImpl struct {
dashId int64 dashId int64
orgId int64 orgId int64
acl []*m.DashboardAclInfoDTO acl []*m.DashboardAclInfoDTO
groups []*m.Team teams []*m.TeamDTO
log log.Logger log log.Logger
} }
@ -186,15 +186,15 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
return g.acl, nil return g.acl, nil
} }
func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { func (g *dashboardGuardianImpl) getTeams() ([]*m.TeamDTO, error) {
if g.groups != nil { if g.teams != nil {
return g.groups, nil return g.teams, nil
} }
query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId} query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
err := bus.Dispatch(&query) err := bus.Dispatch(&query)
g.groups = query.Result g.teams = query.Result
return query.Result, err return query.Result, err
} }

View File

@ -19,7 +19,7 @@ type scenarioContext struct {
givenUser *m.SignedInUser givenUser *m.SignedInUser
givenDashboardID int64 givenDashboardID int64
givenPermissions []*m.DashboardAclInfoDTO givenPermissions []*m.DashboardAclInfoDTO
givenTeams []*m.Team givenTeams []*m.TeamDTO
updatePermissions []*m.DashboardAcl updatePermissions []*m.DashboardAcl
expectedFlags permissionFlags expectedFlags permissionFlags
callerFile string callerFile string
@ -84,11 +84,11 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, per
return nil return nil
}) })
teams := []*m.Team{} teams := []*m.TeamDTO{}
for _, p := range permissions { for _, p := range permissions {
if p.TeamId > 0 { if p.TeamId > 0 {
teams = append(teams, &m.Team{Id: p.TeamId}) teams = append(teams, &m.TeamDTO{Id: p.TeamId})
} }
} }

View File

@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
alert.name, alert.name,
alert.state, alert.state,
alert.new_state_date, alert.new_state_date,
alert.eval_data,
alert.eval_date, alert.eval_date,
alert.execution_error, alert.execution_error,
dashboard.uid as dashboard_uid, dashboard.uid as dashboard_uid,

View File

@ -13,7 +13,7 @@ func mockTimeNow() {
var timeSeed int64 var timeSeed int64
timeNow = func() time.Time { timeNow = func() time.Time {
fakeNow := time.Unix(timeSeed, 0) fakeNow := time.Unix(timeSeed, 0)
timeSeed += 1 timeSeed++
return fakeNow return fakeNow
} }
} }
@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) {
InitTestDB(t) InitTestDB(t)
testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert") testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`))
items := []*m.Alert{ items := []*m.Alert{
{ {
PanelId: 1, PanelId: 1,
@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) {
Message: "Alerting message", Message: "Alerting message",
Settings: simplejson.New(), Settings: simplejson.New(),
Frequency: 1, Frequency: 1,
EvalData: evalData,
}, },
} }
@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) {
alert := alertQuery.Result[0] alert := alertQuery.Result[0]
So(err2, ShouldBeNil) 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.Name, ShouldEqual, "Alerting title")
So(alert.State, ShouldEqual, "pending") 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() { Convey("Viewer cannot read alerts", func() {

View File

@ -181,7 +181,7 @@ func TestDashboardDataAccess(t *testing.T) {
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(query.Result.FolderId, ShouldEqual, 0) So(query.Result.FolderId, ShouldEqual, 0)
So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy) 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.UpdatedBy, ShouldEqual, 100)
So(query.Result.Updated.IsZero(), ShouldBeFalse) 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 { func createUser(name string, role string, isAdmin bool) m.User {
setting.AutoAssignOrg = true setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1
setting.AutoAssignOrgRole = role setting.AutoAssignOrgRole = role
currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin} currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}

View File

@ -17,6 +17,7 @@ func TestAccountDataAccess(t *testing.T) {
Convey("Given single org mode", func() { Convey("Given single org mode", func() {
setting.AutoAssignOrg = true setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1
setting.AutoAssignOrgRole = "Viewer" setting.AutoAssignOrgRole = "Viewer"
Convey("Users should be added to default organization", func() { Convey("Users should be added to default organization", func() {

View File

@ -22,6 +22,16 @@ func init() {
bus.AddHandler("sql", GetTeamMembers) 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 { func CreateTeam(cmd *m.CreateTeamCommand) error {
return inTransaction(func(sess *DBSession) 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 { func SearchTeams(query *m.SearchTeamsQuery) error {
query.Result = m.SearchTeamQueryResult{ query.Result = m.SearchTeamQueryResult{
Teams: make([]*m.SearchTeamDto, 0), Teams: make([]*m.TeamDTO, 0),
} }
queryWithWildcards := "%" + query.Query + "%" queryWithWildcards := "%" + query.Query + "%"
var sql bytes.Buffer var sql bytes.Buffer
params := make([]interface{}, 0) params := make([]interface{}, 0)
sql.WriteString(`select sql.WriteString(getTeamSelectSqlBase())
team.id as id, sql.WriteString(` WHERE team.org_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 = ?`)
params = append(params, query.OrgId) params = append(params, query.OrgId)
@ -186,8 +190,14 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
} }
func GetTeamById(query *m.GetTeamByIdQuery) error { func GetTeamById(query *m.GetTeamByIdQuery) error {
var team m.Team var sql bytes.Buffer
exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team)
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 { if err != nil {
return err return err
} }
@ -202,13 +212,15 @@ func GetTeamById(query *m.GetTeamByIdQuery) error {
// GetTeamsByUser is used by the Guardian when checking a users' permissions // GetTeamsByUser is used by the Guardian when checking a users' permissions
func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
query.Result = make([]*m.Team, 0) query.Result = make([]*m.TeamDTO, 0)
sess := x.Table("team") var sql bytes.Buffer
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)
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 return err
} }

View File

@ -42,16 +42,23 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error
var org m.Org var org m.Org
if setting.AutoAssignOrg { if setting.AutoAssignOrg {
// right now auto assign to org with id 1 has, err := sess.Where("id=?", setting.AutoAssignOrgId).Get(&org)
has, err := sess.Where("id=?", 1).Get(&org)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if has { if has {
return org.Id, nil 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 { } else {
org.Name = cmd.OrgName org.Name = cmd.OrgName
if len(org.Name) == 0 { if len(org.Name) == 0 {

View File

@ -100,6 +100,7 @@ var (
AllowUserSignUp bool AllowUserSignUp bool
AllowUserOrgCreate bool AllowUserOrgCreate bool
AutoAssignOrg bool AutoAssignOrg bool
AutoAssignOrgId int
AutoAssignOrgRole string AutoAssignOrgRole string
VerifyEmailEnabled bool VerifyEmailEnabled bool
LoginHint string LoginHint string
@ -592,6 +593,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
AllowUserSignUp = users.Key("allow_sign_up").MustBool(true) AllowUserSignUp = users.Key("allow_sign_up").MustBool(true)
AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true)
AutoAssignOrg = users.Key("auto_assign_org").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"}) AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
LoginHint = users.Key("login_hint").String() LoginHint = users.Key("login_hint").String()

View File

@ -14,8 +14,10 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb" "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"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface" "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), Results: make(map[string]*tsdb.QueryResult),
} }
errCh := make(chan error, 1) eg, ectx := errgroup.WithContext(ctx)
resCh := make(chan *tsdb.QueryResult, 1)
currentlyExecuting := 0 getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery)
for i, model := range queryContext.Queries { for i, model := range queryContext.Queries {
queryType := model.Model.Get("type").MustString() queryType := model.Model.Get("type").MustString()
if queryType != "timeSeriesQuery" && queryType != "" { if queryType != "timeSeriesQuery" && queryType != "" {
continue continue
} }
currentlyExecuting++
go func(refId string, index int) { RefId := queryContext.Queries[i].RefId
queryRes, err := e.executeQuery(ctx, queryContext.Queries[index].Model, queryContext) query, err := parseQuery(queryContext.Queries[i].Model)
currentlyExecuting-- if err != nil {
if err != nil { result.Results[RefId] = &tsdb.QueryResult{
errCh <- err Error: err,
} else {
queryRes.RefId = refId
resCh <- queryRes
} }
}(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 { if len(getMetricDataQueries) > 0 {
select { for region, getMetricDataQuery := range getMetricDataQueries {
case res := <-resCh: q := getMetricDataQuery
result.Results[res.RefId] = res eg.Go(func() error {
case err := <-errCh: queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
return result, err if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
case <-ctx.Done(): return err
return result, ctx.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 return result, nil
} }
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
query, err := parseQuery(parameters)
if err != nil {
return nil, err
}
client, err := e.getClient(query.Region) client, err := e.getClient(query.Region)
if err != nil { if err != nil {
return nil, err return nil, err
@ -201,6 +235,139 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl
return queryRes, nil 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) { func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) {
var result []*cloudwatch.Dimension var result []*cloudwatch.Dimension
@ -257,6 +424,9 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
return nil, err return nil, err
} }
id := model.Get("id").MustString("")
expression := model.Get("expression").MustString("")
dimensions, err := parseDimensions(model) dimensions, err := parseDimensions(model)
if err != nil { if err != nil {
return nil, err return nil, err
@ -295,6 +465,7 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
alias = "{{metric}}_{{stat}}" alias = "{{metric}}_{{stat}}"
} }
returnData := model.Get("returnData").MustBool(false)
highResolution := model.Get("highResolution").MustBool(false) highResolution := model.Get("highResolution").MustBool(false)
return &CloudWatchQuery{ return &CloudWatchQuery{
@ -306,11 +477,18 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
ExtendedStatistics: aws.StringSlice(extendedStatistics), ExtendedStatistics: aws.StringSlice(extendedStatistics),
Period: period, Period: period,
Alias: alias, Alias: alias,
Id: id,
Expression: expression,
ReturnData: returnData,
HighResolution: highResolution, HighResolution: highResolution,
}, nil }, nil
} }
func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string { 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 := map[string]string{}
data["region"] = query.Region data["region"] = query.Region
data["namespace"] = query.Namespace 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) { func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) {
queryRes := tsdb.NewQueryResult() queryRes := tsdb.NewQueryResult()
queryRes.RefId = query.RefId
var value float64 var value float64
for _, s := range append(query.Statistics, query.ExtendedStatistics...) { for _, s := range append(query.Statistics, query.ExtendedStatistics...) {
series := tsdb.TimeSeries{ series := tsdb.TimeSeries{

View File

@ -5,6 +5,7 @@ import (
) )
type CloudWatchQuery struct { type CloudWatchQuery struct {
RefId string
Region string Region string
Namespace string Namespace string
MetricName string MetricName string
@ -13,5 +14,8 @@ type CloudWatchQuery struct {
ExtendedStatistics []*string ExtendedStatistics []*string
Period int Period int
Alias string Alias string
Id string
Expression string
ReturnData bool
HighResolution bool HighResolution bool
} }

View File

@ -248,13 +248,28 @@ var datePatternReplacements = map[string]string{
func formatDate(t time.Time, pattern string) string { func formatDate(t time.Time, pattern string) string {
var datePattern string var datePattern string
parts := strings.Split(strings.TrimLeft(pattern, "["), "]") base := ""
base := parts[0] ltr := false
if len(parts) == 2 {
datePattern = parts[1] if strings.HasPrefix(pattern, "[") {
} else { parts := strings.Split(strings.TrimLeft(pattern, "["), "]")
datePattern = base base = parts[0]
base = "" 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)) 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) 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 { func patternToLayout(pattern string) string {

View File

@ -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)) 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) { 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") 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) { indexPatternScenario(intervalDaily, "[data-]YYYY.MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) {
So(indices, ShouldHaveLength, 1) So(indices, ShouldHaveLength, 1)
So(indices[0], ShouldEqual, "data-2018.05.15") 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) { indexPatternScenario(intervalWeekly, "[data-]GGGG.WW", tsdb.NewTimeRange(from, to), func(indices []string) {
So(indices, ShouldHaveLength, 1) So(indices, ShouldHaveLength, 1)
So(indices[0], ShouldEqual, "data-2018.20") 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) { indexPatternScenario(intervalMonthly, "[data-]YYYY.MM", tsdb.NewTimeRange(from, to), func(indices []string) {
So(indices, ShouldHaveLength, 1) So(indices, ShouldHaveLength, 1)
So(indices[0], ShouldEqual, "data-2018.05") 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) { indexPatternScenario(intervalYearly, "[data-]YYYY", tsdb.NewTimeRange(from, to), func(indices []string) {
So(indices, ShouldHaveLength, 1) So(indices, ShouldHaveLength, 1)
So(indices[0], ShouldEqual, "data-2018") 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() { Convey("Hourly interval", t, func() {

View File

@ -53,7 +53,13 @@ func generateConnectionString(datasource *models.DataSource) string {
} }
sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full") 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() return u.String()
} }

View File

@ -68,6 +68,7 @@ func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSour
engine.SetMaxOpenConns(10) engine.SetMaxOpenConns(10)
engine.SetMaxIdleConns(10) engine.SetMaxIdleConns(10)
engineCache.versions[dsInfo.Id] = dsInfo.Version
engineCache.cache[dsInfo.Id] = engine engineCache.cache[dsInfo.Id] = engine
e.XormEngine = engine e.XormEngine = engine

View File

@ -21,7 +21,7 @@ func NewTestDataExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, err
} }
func init() { 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) { func (e *TestDataExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {

View File

@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { hot } from 'react-hot-loader'; 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 colors from 'app/core/utils/colors';
import TimeSeries from 'app/core/time_series2'; 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 ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Logs from './Logs';
import Table from './Table'; import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker'; import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { decodePathComponent } from 'app/core/utils/location_util';
function makeTimeSeriesList(dataList, options) { function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => { return dataList.map((seriesData, index) => {
@ -30,74 +34,136 @@ function makeTimeSeriesList(dataList, options) {
}); });
} }
function parseInitialState(initial) { function parseInitialState(initial: string | undefined) {
try { if (initial) {
const parsed = JSON.parse(decodePathComponent(initial)); try {
return { const parsed = JSON.parse(decodePathComponent(initial));
queries: parsed.queries.map(q => q.query), return {
range: parsed.range, datasource: parsed.datasource,
}; queries: parsed.queries.map(q => q.query),
} catch (e) { range: parsed.range,
console.error(e); };
return { queries: [], range: DEFAULT_RANGE }; } catch (e) {
console.error(e);
}
} }
return { datasource: null, queries: [], range: DEFAULT_RANGE };
} }
interface IExploreState { interface IExploreState {
datasource: any; datasource: any;
datasourceError: any; datasourceError: any;
datasourceLoading: any; datasourceLoading: boolean | null;
datasourceMissing: boolean;
graphResult: any; graphResult: any;
initialDatasource?: string;
latency: number; latency: number;
loading: any; loading: any;
logsResult: any;
queries: any; queries: any;
queryError: any; queryError: any;
range: any; range: any;
requestOptions: any; requestOptions: any;
showingGraph: boolean; showingGraph: boolean;
showingLogs: boolean;
showingTable: boolean; showingTable: boolean;
supportsGraph: boolean | null;
supportsLogs: boolean | null;
supportsTable: boolean | null;
tableResult: any; tableResult: any;
} }
// @observer
export class Explore extends React.Component<any, IExploreState> { export class Explore extends React.Component<any, IExploreState> {
datasourceSrv: DatasourceSrv; el: any;
constructor(props) { constructor(props) {
super(props); super(props);
const { range, queries } = parseInitialState(props.routeParams.initial); const { datasource, queries, range } = parseInitialState(props.routeParams.state);
this.state = { this.state = {
datasource: null, datasource: null,
datasourceError: null, datasourceError: null,
datasourceLoading: true, datasourceLoading: null,
datasourceMissing: false,
graphResult: null, graphResult: null,
initialDatasource: datasource,
latency: 0, latency: 0,
loading: false, loading: false,
logsResult: null,
queries: ensureQueries(queries), queries: ensureQueries(queries),
queryError: null, queryError: null,
range: range || { ...DEFAULT_RANGE }, range: range || { ...DEFAULT_RANGE },
requestOptions: null, requestOptions: null,
showingGraph: true, showingGraph: true,
showingLogs: true,
showingTable: true, showingTable: true,
supportsGraph: null,
supportsLogs: null,
supportsTable: null,
tableResult: null, tableResult: null,
...props.initialState, ...props.initialState,
}; };
} }
async componentDidMount() { async componentDidMount() {
const datasource = await this.props.datasourceSrv.get(); const { datasourceSrv } = this.props;
const testResult = await datasource.testDatasource(); const { initialDatasource } = this.state;
if (testResult.status === 'success') { if (!datasourceSrv) {
this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit()); 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 { } else {
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false }); this.setState({ datasourceMissing: true });
} }
} }
componentDidCatch(error) { componentDidCatch(error) {
this.setState({ datasourceError: error });
console.error(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 => { handleAddQueryRow = index => {
const { queries } = this.state; const { queries } = this.state;
const nextQueries = [ const nextQueries = [
@ -108,6 +174,19 @@ export class Explore extends React.Component<any, IExploreState> {
this.setState({ queries: nextQueries }); 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) => { handleChangeQuery = (query, index) => {
const { queries } = this.state; const { queries } = this.state;
const nextQuery = { const nextQuery = {
@ -138,6 +217,10 @@ export class Explore extends React.Component<any, IExploreState> {
this.setState(state => ({ showingGraph: !state.showingGraph })); this.setState(state => ({ showingGraph: !state.showingGraph }));
}; };
handleClickLogsButton = () => {
this.setState(state => ({ showingLogs: !state.showingLogs }));
};
handleClickSplit = () => { handleClickSplit = () => {
const { onChangeSplit } = this.props; const { onChangeSplit } = this.props;
if (onChangeSplit) { if (onChangeSplit) {
@ -159,29 +242,45 @@ export class Explore extends React.Component<any, IExploreState> {
}; };
handleSubmit = () => { handleSubmit = () => {
const { showingGraph, showingTable } = this.state; const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
if (showingTable) { if (showingTable && supportsTable) {
this.runTableQuery(); this.runTableQuery();
} }
if (showingGraph) { if (showingGraph && supportsGraph) {
this.runGraphQuery(); this.runGraphQuery();
} }
if (showingLogs && supportsLogs) {
this.runLogsQuery();
}
}; };
async runGraphQuery() { buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
const { datasource, queries, range } = this.state; 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)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, graphResult: null, queryError: null }); this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
const now = Date.now(); const now = Date.now();
const options = buildQueryOptions({ const options = this.buildQueryOptions({ format: 'time_series', instant: false });
format: 'time_series',
interval: datasource.interval,
instant: false,
range,
queries: queries.map(q => q.query),
});
try { try {
const res = await datasource.query(options); const res = await datasource.query(options);
const result = makeTimeSeriesList(res.data, options); const result = makeTimeSeriesList(res.data, options);
@ -195,18 +294,15 @@ export class Explore extends React.Component<any, IExploreState> {
} }
async runTableQuery() { async runTableQuery() {
const { datasource, queries, range } = this.state; const { datasource, queries } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, queryError: null, tableResult: null }); this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
const now = Date.now(); const now = Date.now();
const options = buildQueryOptions({ const options = this.buildQueryOptions({
format: 'table', format: 'table',
interval: datasource.interval,
instant: true, instant: true,
range,
queries: queries.map(q => q.query),
}); });
try { try {
const res = await datasource.query(options); 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 => { request = url => {
const { datasource } = this.state; const { datasource } = this.state;
return datasource.metadataRequest(url); return datasource.metadataRequest(url);
}; };
render() { render() {
const { position, split } = this.props; const { datasourceSrv, position, split } = this.props;
const { const {
datasource, datasource,
datasourceError, datasourceError,
datasourceLoading, datasourceLoading,
datasourceMissing,
graphResult, graphResult,
latency, latency,
loading, loading,
logsResult,
queries, queries,
queryError, queryError,
range, range,
requestOptions, requestOptions,
showingGraph, showingGraph,
showingLogs,
showingTable, showingTable,
supportsGraph,
supportsLogs,
supportsTable,
tableResult, tableResult,
} = this.state; } = this.state;
const showingBoth = showingGraph && showingTable; const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : '400px'; const graphHeight = showingBoth ? '200px' : '400px';
const graphButtonActive = showingBoth || showingGraph ? 'active' : ''; const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore'; 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 ( return (
<div className={exploreClass}> <div className={exploreClass} ref={this.getRef}>
<div className="navbar"> <div className="navbar">
{position === 'left' ? ( {position === 'left' ? (
<div> <div>
@ -264,6 +396,18 @@ export class Explore extends React.Component<any, IExploreState> {
</button> </button>
</div> </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" /> <div className="navbar__spacer" />
{position === 'left' && !split ? ( {position === 'left' && !split ? (
<div className="navbar-buttons"> <div className="navbar-buttons">
@ -273,12 +417,21 @@ export class Explore extends React.Component<any, IExploreState> {
</div> </div>
) : null} ) : null}
<div className="navbar-buttons"> <div className="navbar-buttons">
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}> {supportsGraph ? (
Graph <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
</button> Graph
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}> </button>
Table ) : null}
</button> {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> </div>
<TimePicker range={range} onChangeTime={this.handleChangeTime} /> <TimePicker range={range} onChangeTime={this.handleChangeTime} />
<div className="navbar-buttons relative"> <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} {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
{datasourceError ? ( {datasourceMissing ? (
<div className="explore-container" title={datasourceError}> <div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
Error connecting to datasource.
</div>
) : null} ) : null}
{datasource ? ( {datasourceError ? (
<div className="explore-container">Error connecting to datasource. [{datasourceError}]</div>
) : null}
{datasource && !datasourceError ? (
<div className="explore-container"> <div className="explore-container">
<QueryRows <QueryRows
queries={queries} 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} {queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
<main className="m-t-2"> <main className="m-t-2">
{showingGraph ? ( {supportsGraph && showingGraph ? (
<Graph <Graph
data={graphResult} data={graphResult}
id={`explore-graph-${position}`} id={`explore-graph-${position}`}
@ -318,7 +473,8 @@ export class Explore extends React.Component<any, IExploreState> {
split={split} split={split}
/> />
) : null} ) : 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> </main>
</div> </div>
) : null} ) : null}

View File

@ -0,0 +1,9 @@
import React from 'react';
export default function({ value }) {
return (
<div>
<pre>{JSON.stringify(value, undefined, 2)}</pre>
</div>
);
}

View 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>
);
}
}

View 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' }]);
});
});
});

View 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;

View File

@ -1,105 +1,163 @@
import _ from 'lodash';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Value } from 'slate'; import { Block, Change, Document, Text, Value } from 'slate';
import { Editor } from 'slate-react'; import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import BracesPlugin from './slate-plugins/braces'; import BracesPlugin from './slate-plugins/braces';
import ClearPlugin from './slate-plugins/clear'; import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline'; import NewlinePlugin from './slate-plugins/newline';
import 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'; import Typeahead from './Typeahead';
const EMPTY_METRIC = '';
export const TYPEAHEAD_DEBOUNCE = 300; export const TYPEAHEAD_DEBOUNCE = 300;
function flattenSuggestions(s) { function flattenSuggestions(s: any[]): any[] {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : []; return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
} }
export const getInitialValue = query => export const makeFragment = (text: string): Document => {
Value.fromJSON({ const lines = text.split('\n').map(line =>
document: { Block.create({
nodes: [ type: 'paragraph',
{ nodes: [Text.create(line)],
object: 'block', })
type: 'paragraph', );
nodes: [
{ const fragment = Document.create({
object: 'text', nodes: lines,
leaves: [
{
text: query,
},
],
},
],
},
],
},
}); });
return fragment;
};
class Portal extends React.Component<any, any> { export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
node: any;
constructor(props) { export interface Suggestion {
super(props); /**
const { index = 0, prefix = 'query' } = props; * The label of this completion item. By default
this.node = document.createElement('div'); * this is also the text that is inserted when selecting
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`); * this completion.
document.body.appendChild(this.node); */
} label: string;
/**
componentWillUnmount() { * The kind of this completion item. Based on the kind
document.body.removeChild(this.node); * an icon is chosen by the editor.
} */
kind?: string;
render() { /**
return ReactDOM.createPortal(this.props.children, this.node); * 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> { export interface SuggestionGroup {
menuEl: any; /**
plugins: any; * 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; resetTimer: any;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
const { prismDefinition = {}, prismLanguage = 'promql' } = props; // Base plugins
this.plugins = [BracesPlugin(), ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
this.plugins = [
BracesPlugin(),
ClearPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
NewlinePlugin(),
PluginPrism({ definition: prismDefinition, language: prismLanguage }),
];
this.state = { this.state = {
labelKeys: {},
labelValues: {},
metrics: props.metrics || [],
suggestions: [], suggestions: [],
typeaheadContext: null,
typeaheadIndex: 0, typeaheadIndex: 0,
typeaheadPrefix: '', typeaheadPrefix: '',
value: getInitialValue(props.initialQuery || ''), typeaheadText: '',
value: getInitialValue(props.initialValue || ''),
}; };
} }
componentDidMount() { componentDidMount() {
this.updateMenu(); this.updateMenu();
if (this.props.metrics === undefined) {
this.fetchMetricNames();
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -111,12 +169,9 @@ class QueryField extends React.Component<any, any> {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) { // initialValue is null in case the user typed
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived); if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
} this.setState({ value: getInitialValue(nextProps.initialValue) });
// initialQuery is null in case the user typed
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
this.setState({ value: getInitialValue(nextProps.initialQuery) });
} }
} }
@ -124,48 +179,28 @@ class QueryField extends React.Component<any, any> {
const changed = value.document !== this.state.value.document; const changed = value.document !== this.state.value.document;
this.setState({ value }, () => { this.setState({ value }, () => {
if (changed) { if (changed) {
this.handleChangeQuery(); this.handleChangeValue();
} }
}); });
window.requestAnimationFrame(this.handleTypeahead); if (changed) {
}; window.requestAnimationFrame(this.handleTypeahead);
onMetricsReceived = () => {
if (!this.state.metrics) {
return;
} }
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 => { handleChangeValue = () => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
handleChangeQuery = () => {
// Send text change to parent // Send text change to parent
const { onQueryChange } = this.props; const { onValueChanged } = this.props;
if (onQueryChange) { if (onValueChanged) {
onQueryChange(Plain.serialize(this.state.value)); onValueChanged(Plain.serialize(this.state.value));
} }
}; };
handleTypeahead = debounce(() => { handleTypeahead = _.debounce(async () => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.anchorNode) { const { cleanText, onTypeahead } = this.props;
if (onTypeahead && selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement; const wrapperNode = selection.anchorNode.parentElement;
const editorNode = wrapperNode.closest('.slate-query-field'); const editorNode = wrapperNode.closest('.slate-query-field');
if (!editorNode || this.state.value.isBlurred) { if (!editorNode || this.state.value.isBlurred) {
@ -174,164 +209,96 @@ class QueryField extends React.Component<any, any> {
} }
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const text = selection.anchorNode.textContent;
const offset = range.startOffset; const offset = range.startOffset;
const prefix = cleanText(text.substr(0, offset)); const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset);
// Determine candidates by context if (cleanText) {
const suggestionGroups = []; prefix = cleanText(prefix);
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,
});
} }
let results = 0; const { suggestions, context, refresher } = onTypeahead({
const filteredSuggestions = suggestionGroups.map(group => { editorNode,
if (group.items) { prefix,
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1); selection,
results += group.items.length; 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); }, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change, suggestion) { applyTypeahead(change: Change, suggestion: Suggestion): Change {
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; 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 if (onWillApplySuggestion) {
switch (typeaheadContext) { suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
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:
} }
this.resetTypeahead(); this.resetTypeahead();
// Remove the current, incomplete text and replace it with the selected suggestion // Remove the current, incomplete text and replace it with the selected suggestion
let backward = typeaheadPrefix.length; const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
const text = cleanText(typeaheadText); const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
const suffixLength = text.length - typeaheadPrefix.length; const suffixLength = text.length - typeaheadPrefix.length;
const offset = typeaheadText.indexOf(typeaheadPrefix); 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; const forward = midWord ? suffixLength + offset : 0;
return ( // If new-lines, apply suggestion as block
change if (suggestionText.match(/\n/)) {
// TODO this line breaks if cursor was moved left and length is longer than whole prefix const fragment = makeFragment(suggestionText);
return change
.deleteBackward(backward) .deleteBackward(backward)
.deleteForward(forward) .deleteForward(forward)
.insertText(suggestion) .insertFragment(fragment)
.focus() .focus();
); }
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.move(move)
.focus();
} }
onKeyDown = (event, change) => { 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 = () => { handleBlur = () => {
const { onBlur } = this.props; const { onBlur } = this.props;
// If we dont wait here, menu clicks wont work because the menu // 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 // Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item); const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change); this.onChange(change);
@ -529,7 +429,7 @@ class QueryField extends React.Component<any, any> {
// Write DOM // Write DOM
requestAnimationFrame(() => { requestAnimationFrame(() => {
menu.style.opacity = 1; menu.style.opacity = '1';
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`; menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + scrollX - 2}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); let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
const flattenedSuggestions = flattenSuggestions(suggestions); const flattenedSuggestions = flattenSuggestions(suggestions);
selectedIndex = selectedIndex % flattenedSuggestions.length || 0; selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map( const selectedItem: Suggestion | null =
i => (typeof i === 'object' ? i.text : i) flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
);
// Create typeahead in DOM root so we can later position it absolutely // Create typeahead in DOM root so we can later position it absolutely
return ( return (
<Portal prefix={portalPrefix}> <Portal prefix={portalPrefix}>
<Typeahead <Typeahead
menuRef={this.menuRef} menuRef={this.menuRef}
selectedItems={selectedKeys} selectedItem={selectedItem}
onClickItem={this.handleClickMenu} onClickItem={this.onClickMenu}
groupedItems={suggestions} groupedItems={suggestions}
/> />
</Portal> </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; export default QueryField;

View File

@ -1,7 +1,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import promql from './slate-plugins/prism/promql'; import QueryField from './PromQueryField';
import QueryField from './QueryField';
class QueryRow extends PureComponent<any, any> { class QueryRow extends PureComponent<any, any> {
constructor(props) { constructor(props) {
@ -62,9 +61,6 @@ class QueryRow extends PureComponent<any, any> {
portalPrefix="explore" portalPrefix="explore"
onPressEnter={this.handlePressEnter} onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery} onQueryChange={this.handleChangeQuery}
placeholder="Enter a PromQL query"
prismLanguage="promql"
prismDefinition={promql}
request={request} request={request}
/> />
</div> </div>

View File

@ -1,17 +1,26 @@
import React from 'react'; import React from 'react';
function scrollIntoView(el) { import { Suggestion, SuggestionGroup } from './QueryField';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) { if (!el || !el.offsetParent) {
return; return;
} }
const container = el.offsetParent; const container = el.offsetParent as HTMLElement;
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
container.scrollTop = el.offsetTop - container.offsetTop; container.scrollTop = el.offsetTop - container.offsetTop;
} }
} }
class TypeaheadItem extends React.PureComponent<any, any> { interface TypeaheadItemProps {
el: any; isSelected: boolean;
item: Suggestion;
onClickItem: (Suggestion) => void;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
el: HTMLElement;
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) { if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el); scrollIntoView(this.el);
@ -22,20 +31,30 @@ class TypeaheadItem extends React.PureComponent<any, any> {
this.el = el; this.el = el;
}; };
onClick = () => {
this.props.onClickItem(this.props.item);
};
render() { render() {
const { hint, isSelected, label, onClickItem } = this.props; const { isSelected, item } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const onClick = () => onClickItem(label);
return ( return (
<li ref={this.getRef} className={className} onClick={onClick}> <li ref={this.getRef} className={className} onClick={this.onClick}>
{label} {item.detail || item.label}
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null} {item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li> </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() { render() {
const { items, label, selected, onClickItem } = this.props; const { items, label, selected, onClickItem } = this.props;
return ( return (
@ -43,16 +62,8 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
<div className="typeahead-group__title">{label}</div> <div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list"> <ul className="typeahead-group__list">
{items.map(item => { {items.map(item => {
const text = typeof item === 'object' ? item.text : item;
const label = typeof item === 'object' ? item.display || item.text : item;
return ( return (
<TypeaheadItem <TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
key={text}
onClickItem={onClickItem}
isSelected={selected.indexOf(text) > -1}
hint={item.hint}
label={label}
/>
); );
})} })}
</ul> </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() { render() {
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props; const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
return ( return (
<ul className="typeahead" ref={menuRef}> <ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => ( {groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} /> <TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
))} ))}
</ul> </ul>
); );

View File

@ -1,67 +1,368 @@
/* tslint:disable max-line-length */
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
const AGGREGATION_OPERATORS = [ const AGGREGATION_OPERATORS = [
'sum', {
'min', label: 'sum',
'max', insertText: 'sum()',
'avg', documentation: 'Calculate sum over dimensions',
'stddev', },
'stdvar', {
'count', label: 'min',
'count_values', insertText: 'min()',
'bottomk', documentation: 'Select minimum over dimensions',
'topk', },
'quantile', {
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 = [ export const FUNCTIONS = [
...AGGREGATION_OPERATORS, ...AGGREGATION_OPERATORS,
'abs', {
'absent', insertText: 'abs()',
'ceil', label: 'abs',
'changes', detail: 'abs(v instant-vector)',
'clamp_max', documentation: 'Returns the input vector with all sample values converted to their absolute value.',
'clamp_min', },
'count_scalar', {
'day_of_month', insertText: 'absent()',
'day_of_week', label: 'absent',
'days_in_month', detail: 'absent(v instant-vector)',
'delta', documentation:
'deriv', '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.',
'drop_common_labels', },
'exp', {
'floor', insertText: 'ceil()',
'histogram_quantile', label: 'ceil',
'holt_winters', detail: 'ceil(v instant-vector)',
'hour', documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
'idelta', },
'increase', {
'irate', insertText: 'changes()',
'label_replace', label: 'changes',
'ln', detail: 'changes(v range-vector)',
'log2', documentation:
'log10', '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.',
'minute', },
'month', {
'predict_linear', insertText: 'clamp_max()',
'rate', label: 'clamp_max',
'resets', detail: 'clamp_max(v instant-vector, max scalar)',
'round', documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
'scalar', },
'sort', {
'sort_desc', insertText: 'clamp_min()',
'sqrt', label: 'clamp_min',
'time', detail: 'clamp_min(v instant-vector, min scalar)',
'vector', documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
'year', },
'avg_over_time', {
'min_over_time', insertText: 'count_scalar()',
'max_over_time', label: 'count_scalar',
'sum_over_time', detail: 'count_scalar(v instant-vector)',
'count_over_time', documentation:
'quantile_over_time', '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.',
'stddev_over_time', },
'stdvar_over_time', {
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 = { 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': [ 'context-range': [
{ {
pattern: /\[[^\]]*(?=])/, // [1m] pattern: /\[[^\]]*(?=])/, // [1m]

View File

@ -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) { export function generateQueryKey(index = 0) {
return `Q-${Date.now()}-${Math.random()}-${index}`; return `Q-${Date.now()}-${Math.random()}-${index}`;
} }

View File

@ -54,7 +54,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
<PageHeader model={nav as any} /> <PageHeader model={nav as any} />
<div className="page-container page-body"> <div className="page-container page-body">
<div className="page-action-bar"> <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}> <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" /> <i className="gicon gicon-question gicon--has-hover" />
</Tooltip> </Tooltip>
@ -68,7 +68,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
</button> </button>
</div> </div>
<SlideDown in={permissions.isAddPermissionsVisible}> <SlideDown in={permissions.isAddPermissionsVisible}>
<AddPermissions permissions={permissions} backendSrv={backendSrv} /> <AddPermissions permissions={permissions} />
</SlideDown> </SlideDown>
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} /> <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
</div> </div>

View 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);

View 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);

View 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);

View 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);

View 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);

View File

@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import LoginBackground from './components/Login/LoginBackground'; import LoginBackground from './components/Login/LoginBackground';
import { SearchResult } from './components/search/SearchResult'; import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter'; import { TagFilter } from './components/TagFilter/TagFilter';
import UserPicker from './components/Picker/UserPicker';
import DashboardPermissions from './components/Permissions/DashboardPermissions'; import DashboardPermissions from './components/Permissions/DashboardPermissions';
export function registerAngularDirectives() { export function registerAngularDirectives() {
@ -19,6 +18,5 @@ export function registerAngularDirectives() {
['onSelect', { watchDepth: 'reference' }], ['onSelect', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }], ['tagOptions', { watchDepth: 'reference' }],
]); ]);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']); react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
} }

View 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>
);
};

View File

@ -1,32 +1,32 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme';
import AddPermissions from './AddPermissions'; import AddPermissions from './AddPermissions';
import { RootStore } from 'app/stores/RootStore/RootStore'; import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv } from 'test/mocks/common'; import { getBackendSrv } from 'app/core/services/backend_srv';
import { shallow } from 'enzyme';
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', () => { describe('AddPermissions', () => {
let wrapper; let wrapper;
let store; let store;
let instance; let instance;
let backendSrv: any = getBackendSrv();
beforeAll(() => { beforeAll(() => {
backendSrv.get.mockReturnValue( store = RootStore.create({}, { backendSrv: backendSrv });
Promise.resolve([ wrapper = shallow(<AddPermissions permissions={store.permissions} />);
{ 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} />);
instance = wrapper.instance(); instance = wrapper.instance();
return store.permissions.load(1, true, false); return store.permissions.load(1, true, false);
}); });
@ -43,8 +43,8 @@ describe('AddPermissions', () => {
login: 'user2', login: 'user2',
}; };
instance.typeChanged(evt); instance.onTypeChanged(evt);
instance.userPicked(userItem); instance.onUserSelected(userItem);
wrapper.update(); wrapper.update();
@ -70,8 +70,8 @@ describe('AddPermissions', () => {
name: 'ug1', name: 'ug1',
}; };
instance.typeChanged(evt); instance.onTypeChanged(evt);
instance.teamPicked(teamItem); instance.onTeamSelected(teamItem);
wrapper.update(); wrapper.update();

View File

@ -1,24 +1,19 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore'; import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker'; import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker'; import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker'; import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
export interface IProps { export interface Props {
permissions: any; permissions: any;
backendSrv: any;
} }
@observer @observer
class AddPermissions extends Component<IProps, any> { class AddPermissions extends Component<Props, any> {
constructor(props) { constructor(props) {
super(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() { componentWillMount() {
@ -26,49 +21,49 @@ class AddPermissions extends Component<IProps, any> {
permissions.resetNewType(); permissions.resetNewType();
} }
typeChanged(evt) { onTypeChanged = evt => {
const { value } = evt.target; const { value } = evt.target;
const { permissions } = this.props; const { permissions } = this.props;
permissions.setNewType(value); permissions.setNewType(value);
} };
userPicked(user: User) { onUserSelected = (user: User) => {
const { permissions } = this.props; const { permissions } = this.props;
if (!user) { if (!user) {
permissions.newItem.setUser(null, null); permissions.newItem.setUser(null, null);
return; return;
} }
return permissions.newItem.setUser(user.id, user.login, user.avatarUrl); return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
} };
teamPicked(team: Team) { onTeamSelected = (team: Team) => {
const { permissions } = this.props; const { permissions } = this.props;
if (!team) { if (!team) {
permissions.newItem.setTeam(null, null); permissions.newItem.setTeam(null, null);
return; return;
} }
return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl); return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
} };
permissionPicked(permission: OptionWithDescription) { onPermissionChanged = (permission: OptionWithDescription) => {
const { permissions } = this.props; const { permissions } = this.props;
return permissions.newItem.setPermission(permission.value); return permissions.newItem.setPermission(permission.value);
} };
resetNewType() { resetNewType() {
const { permissions } = this.props; const { permissions } = this.props;
return permissions.resetNewType(); return permissions.resetNewType();
} }
handleSubmit(evt) { onSubmit = evt => {
evt.preventDefault(); evt.preventDefault();
const { permissions } = this.props; const { permissions } = this.props;
permissions.addStoreItem(); permissions.addStoreItem();
} };
render() { render() {
const { permissions, backendSrv } = this.props; const { permissions } = this.props;
const newItem = permissions.newItem; const newItem = permissions.newItem;
const pickerClassName = 'width-20'; 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}> <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
<i className="fa fa-close" /> <i className="fa fa-close" />
</button> </button>
<form name="addPermission" onSubmit={this.handleSubmit}> <form name="addPermission" onSubmit={this.onSubmit}>
<h6>Add Permission For</h6> <h5>Add Permission For</h5>
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form"> <div className="gf-form">
<div className="gf-form-select-wrapper"> <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) => { {aclTypes.map((option, idx) => {
return ( return (
<option key={idx} value={option.value}> <option key={idx} value={option.value}>
@ -98,30 +93,20 @@ class AddPermissions extends Component<IProps, any> {
{newItem.type === 'User' ? ( {newItem.type === 'User' ? (
<div className="gf-form"> <div className="gf-form">
<UserPicker <UserPicker onSelected={this.onUserSelected} value={newItem.userId} className={pickerClassName} />
backendSrv={backendSrv}
handlePicked={this.userPicked}
value={newItem.userId}
className={pickerClassName}
/>
</div> </div>
) : null} ) : null}
{newItem.type === 'Group' ? ( {newItem.type === 'Group' ? (
<div className="gf-form"> <div className="gf-form">
<TeamPicker <TeamPicker onSelected={this.onTeamSelected} value={newItem.teamId} className={pickerClassName} />
backendSrv={backendSrv}
handlePicked={this.teamPicked}
value={newItem.teamId}
className={pickerClassName}
/>
</div> </div>
) : null} ) : null}
<div className="gf-form"> <div className="gf-form">
<DescriptionPicker <DescriptionPicker
optionsWithDesc={permissionOptions} optionsWithDesc={permissionOptions}
handlePicked={this.permissionPicked} onSelected={this.onPermissionChanged}
value={newItem.permission} value={newItem.permission}
disabled={false} disabled={false}
className={'gf-form-input--form-dropdown-right'} className={'gf-form-input--form-dropdown-right'}

View File

@ -8,13 +8,14 @@ import AddPermissions from 'app/core/components/Permissions/AddPermissions';
import SlideDown from 'app/core/components/Animations/SlideDown'; import SlideDown from 'app/core/components/Animations/SlideDown';
import { FolderInfo } from './FolderInfo'; import { FolderInfo } from './FolderInfo';
export interface IProps { export interface Props {
dashboardId: number; dashboardId: number;
folder?: FolderInfo; folder?: FolderInfo;
backendSrv: any; backendSrv: any;
} }
@observer @observer
class DashboardPermissions extends Component<IProps, any> { class DashboardPermissions extends Component<Props, any> {
permissions: any; permissions: any;
constructor(props) { constructor(props) {
@ -53,7 +54,7 @@ class DashboardPermissions extends Component<IProps, any> {
</div> </div>
</div> </div>
<SlideDown in={this.permissions.isAddPermissionsVisible}> <SlideDown in={this.permissions.isAddPermissionsVisible}>
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} /> <AddPermissions permissions={this.permissions} />
</SlideDown> </SlideDown>
<Permissions <Permissions
permissions={this.permissions} permissions={this.permissions}

View File

@ -25,7 +25,7 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
<div className="gf-form"> <div className="gf-form">
<DescriptionPicker <DescriptionPicker
optionsWithDesc={permissionOptions} optionsWithDesc={permissionOptions}
handlePicked={() => {}} onSelected={() => {}}
value={item.permission} value={item.permission}
disabled={true} disabled={true}
className={'gf-form-input--form-dropdown-right'} className={'gf-form-input--form-dropdown-right'}

View File

@ -68,7 +68,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
<div className="gf-form"> <div className="gf-form">
<DescriptionPicker <DescriptionPicker
optionsWithDesc={permissionOptions} optionsWithDesc={permissionOptions}
handlePicked={handleChangePermission} onSelected={handleChangePermission}
value={item.permission} value={item.permission}
disabled={item.inherited} disabled={item.inherited}
className={'gf-form-input--form-dropdown-right'} className={'gf-form-input--form-dropdown-right'}

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import Select from 'react-select'; import Select from 'react-select';
import DescriptionOption from './DescriptionOption'; import DescriptionOption from './DescriptionOption';
export interface IProps { export interface Props {
optionsWithDesc: OptionWithDescription[]; optionsWithDesc: OptionWithDescription[];
handlePicked: (permission) => void; onSelected: (permission) => void;
value: number; value: number;
disabled: boolean; disabled: boolean;
className?: string; className?: string;
@ -16,14 +16,14 @@ export interface OptionWithDescription {
description: string; description: string;
} }
class DescriptionPicker extends Component<IProps, any> { class DescriptionPicker extends Component<Props, any> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {}; this.state = {};
} }
render() { render() {
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props; const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
return ( return (
<div className="permissions-picker"> <div className="permissions-picker">
@ -34,7 +34,7 @@ class DescriptionPicker extends Component<IProps, any> {
clearable={false} clearable={false}
labelKey="label" labelKey="label"
options={optionsWithDesc} options={optionsWithDesc}
onChange={handlePicked} onChange={onSelected}
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`} className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={DescriptionOption} optionComponent={DescriptionOption}
placeholder="Choose" placeholder="Choose"

View File

@ -1,19 +1,23 @@
import React from 'react'; import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import TeamPicker from './TeamPicker'; import { TeamPicker } from './TeamPicker';
const model = { jest.mock('app/core/services/backend_srv', () => ({
backendSrv: { getBackendSrv: () => {
get: () => { return {
return new Promise((resolve, reject) => {}); get: () => {
}, return Promise.resolve([]);
},
};
}, },
handlePicked: () => {}, }));
};
describe('TeamPicker', () => { describe('TeamPicker', () => {
it('renders correctly', () => { it('renders correctly', () => {
const tree = renderer.create(<TeamPicker {...model} />).toJSON(); const props = {
onSelected: () => {},
};
const tree = renderer.create(<TeamPicker {...props} />).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
}); });

View File

@ -1,18 +1,19 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Select from 'react-select'; import Select from 'react-select';
import PickerOption from './PickerOption'; import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
export interface IProps { export interface Props {
backendSrv: any; onSelected: (team: Team) => void;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string; value?: string;
className?: string; className?: string;
} }
export interface State {
isLoading;
}
export interface Team { export interface Team {
id: number; id: number;
label: string; label: string;
@ -20,13 +21,12 @@ export interface Team {
avatarUrl: string; avatarUrl: string;
} }
class TeamPicker extends Component<IProps, any> { export class TeamPicker extends Component<Props, State> {
debouncedSearch: any; debouncedSearch: any;
backendSrv: any;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {}; this.state = { isLoading: false };
this.search = this.search.bind(this); this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, { this.debouncedSearch = debounce(this.search, 300, {
@ -36,9 +36,9 @@ class TeamPicker extends Component<IProps, any> {
} }
search(query?: string) { 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 => { return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
const teams = result.teams.map(team => { const teams = result.teams.map(team => {
return { return {
@ -49,18 +49,18 @@ class TeamPicker extends Component<IProps, any> {
}; };
}); });
toggleLoading(false); this.setState({ isLoading: false });
return { options: teams }; return { options: teams };
}); });
} }
render() { render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; const { onSelected, value, className } = this.props;
const { isLoading, handlePicked, value, className } = this.props; const { isLoading } = this.state;
return ( return (
<div className="user-picker"> <div className="user-picker">
<AsyncComponent <Select.Async
valueKey="id" valueKey="id"
multi={false} multi={false}
labelKey="label" labelKey="label"
@ -69,10 +69,10 @@ class TeamPicker extends Component<IProps, any> {
loadOptions={this.debouncedSearch} loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..." loadingPlaceholder="Loading..."
noResultsText="No teams found" noResultsText="No teams found"
onChange={handlePicked} onChange={onSelected}
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`} className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption} optionComponent={PickerOption}
placeholder="Choose" placeholder="Select a team"
value={value} value={value}
autosize={true} autosize={true}
/> />
@ -80,5 +80,3 @@ class TeamPicker extends Component<IProps, any> {
); );
} }
} }
export default withPicker(TeamPicker);

View File

@ -1,19 +1,20 @@
import React from 'react'; import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import UserPicker from './UserPicker'; import { UserPicker } from './UserPicker';
const model = { jest.mock('app/core/services/backend_srv', () => ({
backendSrv: { getBackendSrv: () => {
get: () => { return {
return new Promise((resolve, reject) => {}); get: () => {
}, return Promise.resolve([]);
},
};
}, },
handlePicked: () => {}, }));
};
describe('UserPicker', () => { describe('UserPicker', () => {
it('renders correctly', () => { it('renders correctly', () => {
const tree = renderer.create(<UserPicker {...model} />).toJSON(); const tree = renderer.create(<UserPicker onSelected={() => {}} />).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
}); });

View File

@ -1,18 +1,19 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Select from 'react-select'; import Select from 'react-select';
import PickerOption from './PickerOption'; import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
export interface IProps { export interface Props {
backendSrv: any; onSelected: (user: User) => void;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string; value?: string;
className?: string; className?: string;
} }
export interface State {
isLoading: boolean;
}
export interface User { export interface User {
id: number; id: number;
label: string; label: string;
@ -20,13 +21,12 @@ export interface User {
login: string; login: string;
} }
class UserPicker extends Component<IProps, any> { export class UserPicker extends Component<Props, State> {
debouncedSearch: any; debouncedSearch: any;
backendSrv: any;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {}; this.state = { isLoading: false };
this.search = this.search.bind(this); this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, { this.debouncedSearch = debounce(this.search, 300, {
@ -36,29 +36,34 @@ class UserPicker extends Component<IProps, any> {
} }
search(query?: string) { search(query?: string) {
const { toggleLoading, backendSrv } = this.props; const backendSrv = getBackendSrv();
toggleLoading(true); this.setState({ isLoading: true });
return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
const users = result.map(user => { return backendSrv
.get(`/api/org/users?query=${query}&limit=10`)
.then(result => {
return { return {
id: user.userId, options: result.map(user => ({
label: `${user.login} - ${user.email}`, id: user.userId,
avatarUrl: user.avatarUrl, label: `${user.login} - ${user.email}`,
login: user.login, avatarUrl: user.avatarUrl,
login: user.login,
})),
}; };
})
.finally(() => {
this.setState({ isLoading: false });
}); });
toggleLoading(false);
return { options: users };
});
} }
render() { render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; const { value, className } = this.props;
const { isLoading, handlePicked, value, className } = this.props; const { isLoading } = this.state;
return ( return (
<div className="user-picker"> <div className="user-picker">
<AsyncComponent <Select.Async
valueKey="id" valueKey="id"
multi={false} multi={false}
labelKey="label" labelKey="label"
@ -67,10 +72,10 @@ class UserPicker extends Component<IProps, any> {
loadOptions={this.debouncedSearch} loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..." loadingPlaceholder="Loading..."
noResultsText="No users found" noResultsText="No users found"
onChange={handlePicked} onChange={this.props.onSelected}
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`} className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption} optionComponent={PickerOption}
placeholder="Choose" placeholder="Select user"
value={value} value={value}
autosize={true} autosize={true}
/> />
@ -78,5 +83,3 @@ class UserPicker extends Component<IProps, any> {
); );
} }
} }
export default withPicker(UserPicker);

View File

@ -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} />;
}
};
}

View File

@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events';
import Drop from 'tether-drop'; import Drop from 'tether-drop';
import { createStore } from 'app/stores/store'; import { createStore } from 'app/stores/store';
import colors from 'app/core/utils/colors'; 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'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
export class GrafanaCtrl { export class GrafanaCtrl {
@ -24,6 +24,8 @@ export class GrafanaCtrl {
backendSrv: BackendSrv, backendSrv: BackendSrv,
datasourceSrv: DatasourceSrv datasourceSrv: DatasourceSrv
) { ) {
// sets singleston instances for angular services so react components can access them
setBackendSrv(backendSrv);
createStore({ backendSrv, datasourceSrv }); createStore({ backendSrv, datasourceSrv });
$scope.init = function() { $scope.init = function() {

View File

@ -29,11 +29,13 @@ export function pageScrollbar() {
scope.$on('$routeChangeSuccess', () => { scope.$on('$routeChangeSuccess', () => {
lastPos = 0; lastPos = 0;
elem[0].scrollTop = 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].tabIndex = -1;
elem[0].focus(); // Focus page to enable scrolling by keyboard
elem[0].focus({ preventScroll: true });
}, },
}; };
} }

View File

@ -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);

View File

@ -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);

View File

@ -45,8 +45,6 @@ import { KeybindingSrv } from './services/keybindingSrv';
import { helpModal } from './components/help/help'; import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer'; import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv, NavModel } from './nav_model_srv'; 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 { geminiScrollbar } from './components/scroll/scroll';
import { pageScrollbar } from './components/scroll/page_scroll'; import { pageScrollbar } from './components/scroll/page_scroll';
import { gfPageDirective } from './components/gf_page'; import { gfPageDirective } from './components/gf_page';
@ -85,8 +83,6 @@ export {
JsonExplorer, JsonExplorer,
NavModelSrv, NavModelSrv,
NavModel, NavModel,
userPicker,
teamPicker,
geminiScrollbar, geminiScrollbar,
pageScrollbar, pageScrollbar,
gfPageDirective, gfPageDirective,

View 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[];
}

View File

@ -368,3 +368,17 @@ export class BackendSrv {
} }
coreModule.service('backendSrv', 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;
}

View File

@ -191,7 +191,7 @@ export class KeybindingSrv {
range, range,
}; };
const exploreState = encodePathComponent(JSON.stringify(state)); const exploreState = encodePathComponent(JSON.stringify(state));
this.$location.url(`/explore/${exploreState}`); this.$location.url(`/explore?state=${exploreState}`);
} }
} }
}); });

View File

@ -449,6 +449,7 @@ kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr'); kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk'); kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk');
kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF'); kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF');
kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł');
// Data (Binary) // Data (Binary)
kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b'); kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
@ -880,6 +881,7 @@ kbn.getUnitFormats = function() {
{ text: 'Swedish Krona (kr)', value: 'currencySEK' }, { text: 'Swedish Krona (kr)', value: 'currencySEK' },
{ text: 'Czech koruna (czk)', value: 'currencyCZK' }, { text: 'Czech koruna (czk)', value: 'currencyCZK' },
{ text: 'Swiss franc (CHF)', value: 'currencyCHF' }, { text: 'Swiss franc (CHF)', value: 'currencyCHF' },
{ text: 'Polish Złoty (PLN)', value: 'currencyPLN' },
], ],
}, },
{ {
@ -957,7 +959,7 @@ kbn.getUnitFormats = function() {
text: 'throughput', text: 'throughput',
submenu: [ submenu: [
{ text: 'ops/sec (ops)', value: 'ops' }, { 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: 'reads/sec (rps)', value: 'rps' },
{ text: 'writes/sec (wps)', value: 'wps' }, { text: 'writes/sec (wps)', value: 'wps' },
{ text: 'I/O ops/sec (iops)', value: 'iops' }, { text: 'I/O ops/sec (iops)', value: 'iops' },

View File

@ -3,7 +3,7 @@
<div> <div>
<a class="navbar-page-btn" ng-click="ctrl.showSearch()"> <a class="navbar-page-btn" ng-click="ctrl.showSearch()">
<i class="gicon gicon-dashboard"></i> <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> <i class="fa fa-caret-down"></i>
</a> </a>
</div> </div>

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