mirror of
https://github.com/grafana/grafana.git
synced 2026-01-08 21:22:59 +08:00
Compare commits
66 Commits
packages@6
...
v6.3.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a57932af | ||
|
|
62a226b1c3 | ||
|
|
a38dcc3ac7 | ||
|
|
c39b0e246b | ||
|
|
f8f1f506ed | ||
|
|
9d57a1f192 | ||
|
|
ba4a870632 | ||
|
|
ef9ec32c32 | ||
|
|
17235e4bd1 | ||
|
|
eb82b77782 | ||
|
|
3af8aa5c4f | ||
|
|
5b588af73c | ||
|
|
5ec6eccfac | ||
|
|
237e0e8631 | ||
|
|
7a165febf3 | ||
|
|
fd7c38c62f | ||
|
|
00519f1105 | ||
|
|
c8740d98b0 | ||
|
|
54030deaf6 | ||
|
|
880f6b63a1 | ||
|
|
d8ddfa5622 | ||
|
|
9af2ea1b53 | ||
|
|
d98ee66bd8 | ||
|
|
d3ecc963e6 | ||
|
|
0961ec9748 | ||
|
|
a2c7570c5c | ||
|
|
9cbe0abb44 | ||
|
|
2fecdaf6b4 | ||
|
|
a5f360e50e | ||
|
|
99f2386bd9 | ||
|
|
bffa956068 | ||
|
|
1b7a54c084 | ||
|
|
8128eb6c77 | ||
|
|
10c4d6eccc | ||
|
|
8a062dde35 | ||
|
|
041c1c3cb1 | ||
|
|
aba6df2e09 | ||
|
|
5192504748 | ||
|
|
8a0e2ac3a4 | ||
|
|
373d082363 | ||
|
|
94765294a2 | ||
|
|
1f482a5f0b | ||
|
|
ae04813045 | ||
|
|
3d7c52dcd1 | ||
|
|
81f8158010 | ||
|
|
5de014a91d | ||
|
|
777311a759 | ||
|
|
829b5d0d40 | ||
|
|
12878409db | ||
|
|
ffa9429c68 | ||
|
|
6649c5d75b | ||
|
|
d6e8129588 | ||
|
|
6a3a2f5f94 | ||
|
|
5d3a60d46e | ||
|
|
5f0a7f43c3 | ||
|
|
ebff883016 | ||
|
|
81ff856568 | ||
|
|
648aa62264 | ||
|
|
c32365f424 | ||
|
|
6599bdc7f1 | ||
|
|
b44be990bc | ||
|
|
9e87dbb153 | ||
|
|
25506829be | ||
|
|
d918d1f5f4 | ||
|
|
332920954e | ||
|
|
78ca55f3d7 |
@@ -98,6 +98,34 @@ jobs:
|
||||
path: public/e2e-test/screenShots/theOutput
|
||||
destination: output-screenshots
|
||||
|
||||
end-to-end-test-release:
|
||||
docker:
|
||||
- image: circleci/node:10-browsers
|
||||
- image: grafana/grafana-dev:$CIRCLE_TAG
|
||||
steps:
|
||||
- run: dockerize -wait tcp://127.0.0.1:3000 -timeout 120s
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: yarn install
|
||||
command: 'yarn install --pure-lockfile --no-progress'
|
||||
no_output_timeout: 5m
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: run end-to-end tests
|
||||
command: 'env BASE_URL=http://127.0.0.1:3000 yarn e2e-tests'
|
||||
no_output_timeout: 5m
|
||||
- store_artifacts:
|
||||
path: public/e2e-test/screenShots/theTruth
|
||||
destination: expected-screenshots
|
||||
- store_artifacts:
|
||||
path: public/e2e-test/screenShots/theOutput
|
||||
destination: output-screenshots
|
||||
|
||||
codespell:
|
||||
docker:
|
||||
- image: circleci/python
|
||||
@@ -127,6 +155,15 @@ jobs:
|
||||
name: Lint Go
|
||||
command: 'make lint-go'
|
||||
|
||||
shellcheck:
|
||||
machine: true
|
||||
working_directory: ~/go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: ShellCheck
|
||||
command: 'make shellcheck'
|
||||
|
||||
test-frontend:
|
||||
docker:
|
||||
- image: circleci/node:10
|
||||
@@ -635,6 +672,8 @@ workflows:
|
||||
filters: *filter-only-master
|
||||
- lint-go:
|
||||
filters: *filter-only-master
|
||||
- shellcheck:
|
||||
filters: *filter-only-master
|
||||
- test-frontend:
|
||||
filters: *filter-only-master
|
||||
- test-backend:
|
||||
@@ -650,6 +689,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
- build-oss-msi
|
||||
@@ -662,6 +702,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-master
|
||||
@@ -672,6 +713,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
- build-all-enterprise
|
||||
@@ -683,6 +725,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-master
|
||||
@@ -704,6 +747,8 @@ workflows:
|
||||
filters: *filter-only-release
|
||||
- lint-go:
|
||||
filters: *filter-only-release
|
||||
- shellcheck:
|
||||
filters: *filter-only-release
|
||||
- test-frontend:
|
||||
filters: *filter-only-release
|
||||
- test-backend:
|
||||
@@ -719,6 +764,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
- build-oss-msi
|
||||
@@ -731,6 +777,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-release
|
||||
@@ -742,6 +789,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-release
|
||||
@@ -752,9 +800,14 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-release
|
||||
- end-to-end-test-release:
|
||||
requires:
|
||||
- grafana-docker-release
|
||||
filters: *filter-only-release
|
||||
|
||||
build-branches-and-prs:
|
||||
jobs:
|
||||
@@ -771,6 +824,10 @@ workflows:
|
||||
filters: *filter-not-release-or-master
|
||||
- lint-go:
|
||||
filters: *filter-not-release-or-master
|
||||
- lint-go:
|
||||
filters: *filter-not-release-or-master
|
||||
- shellcheck:
|
||||
filters: *filter-not-release-or-master
|
||||
- test-frontend:
|
||||
filters: *filter-not-release-or-master
|
||||
- test-backend:
|
||||
@@ -788,6 +845,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
- cache-server-test
|
||||
@@ -799,6 +857,7 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- lint-go
|
||||
- shellcheck
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
- cache-server-test
|
||||
|
||||
@@ -33,7 +33,7 @@ ENV NODE_ENV production
|
||||
RUN ./node_modules/.bin/grunt build
|
||||
|
||||
# Final container
|
||||
FROM ubuntu:latest
|
||||
FROM ubuntu:18.04
|
||||
|
||||
LABEL maintainer="Grafana team <hello@grafana.com>"
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -2,8 +2,9 @@
|
||||
|
||||
.PHONY: all deps-go deps-js deps build-go build-server build-cli build-js build build-docker-dev build-docker-full lint-go gosec revive golangci-lint go-vet test-go test-js test run clean devenv devenv-down revive-alerting
|
||||
|
||||
GO := GO111MODULE=on go
|
||||
GO_FILES := ./pkg/...
|
||||
GO = GO111MODULE=on go
|
||||
GO_FILES ?= ./pkg/...
|
||||
SH_FILES ?= $(shell find ./scripts -name *.sh)
|
||||
|
||||
all: deps build
|
||||
|
||||
@@ -111,6 +112,11 @@ go-vet:
|
||||
|
||||
lint-go: go-vet golangci-lint revive revive-alerting gosec
|
||||
|
||||
# with disabled SC1071 we are ignored some TCL,Expect `/usr/bin/env expect` scripts
|
||||
shellcheck: $(SH_FILES)
|
||||
@docker run --rm -v "$$PWD:/mnt" koalaman/shellcheck:stable \
|
||||
$(SH_FILES) -e SC1071
|
||||
|
||||
run: scripts/go/bin/bra
|
||||
@scripts/go/bin/bra run
|
||||
|
||||
|
||||
@@ -379,17 +379,45 @@ send_client_credentials_via_post = false
|
||||
|
||||
#################################### SAML Auth ###########################
|
||||
[auth.saml] # Enterprise only
|
||||
# Defaults to false. If true, the feature is enabled
|
||||
enabled = false
|
||||
private_key =
|
||||
private_key_path =
|
||||
|
||||
# Base64-encoded public X.509 certificate. Used to sign requests to the IdP
|
||||
certificate =
|
||||
|
||||
# Path to the public X.509 certificate. Used to sign requests to the IdP
|
||||
certificate_path =
|
||||
|
||||
# Base64-encoded private key. Used to decrypt assertions from the IdP
|
||||
private_key =
|
||||
|
||||
# Path to the private key. Used to decrypt assertions from the IdP
|
||||
private_key_path =
|
||||
|
||||
# Base64-encoded IdP SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata =
|
||||
|
||||
# Path to the SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata_path =
|
||||
|
||||
# URL to fetch SAML IdP metadata. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata_url =
|
||||
|
||||
# Duration, since the IdP issued a response and the SP is allowed to process it. Defaults to 90 seconds
|
||||
max_issue_delay = 90s
|
||||
|
||||
# Duration, for how long the SP's metadata should be valid. Defaults to 48 hours
|
||||
metadata_valid_duration = 48h
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's name
|
||||
assertion_attribute_name = displayName
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's login handle
|
||||
assertion_attribute_login = mail
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's email
|
||||
assertion_attribute_email = mail
|
||||
|
||||
#################################### Basic Auth ##########################
|
||||
[auth.basic]
|
||||
enabled = true
|
||||
|
||||
@@ -334,18 +334,46 @@
|
||||
;send_client_credentials_via_post = false
|
||||
|
||||
#################################### SAML Auth ###########################
|
||||
;[auth.saml] # Enterprise only
|
||||
[auth.saml] # Enterprise only
|
||||
# Defaults to false. If true, the feature is enabled.
|
||||
;enabled = false
|
||||
;private_key =
|
||||
;private_key_path =
|
||||
|
||||
# Base64-encoded public X.509 certificate. Used to sign requests to the IdP
|
||||
;certificate =
|
||||
|
||||
# Path to the public X.509 certificate. Used to sign requests to the IdP
|
||||
;certificate_path =
|
||||
|
||||
# Base64-encoded private key. Used to decrypt assertions from the IdP
|
||||
;private_key =
|
||||
|
||||
;# Path to the private key. Used to decrypt assertions from the IdP
|
||||
;private_key_path =
|
||||
|
||||
# Base64-encoded IdP SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
;idp_metadata =
|
||||
|
||||
# Path to the SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
;idp_metadata_path =
|
||||
|
||||
# URL to fetch SAML IdP metadata. Used to verify and obtain binding locations from the IdP
|
||||
;idp_metadata_url =
|
||||
|
||||
# Duration, since the IdP issued a response and the SP is allowed to process it. Defaults to 90 seconds.
|
||||
;max_issue_delay = 90s
|
||||
|
||||
# Duration, for how long the SP's metadata should be valid. Defaults to 48 hours.
|
||||
;metadata_valid_duration = 48h
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's name
|
||||
;assertion_attribute_name = displayName
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's login handle
|
||||
;assertion_attribute_login = mail
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's email
|
||||
;assertion_attribute_email = mail
|
||||
|
||||
#################################### Grafana.com Auth ####################
|
||||
[auth.grafana_com]
|
||||
;enabled = false
|
||||
|
||||
@@ -28,38 +28,6 @@ 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"
|
||||
|
||||
57
devenv/docker/blocks/openldap/ldap_posix_dev.toml
Normal file
57
devenv/docker/blocks/openldap/ldap_posix_dev.toml
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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'
|
||||
|
||||
# An array of base dns to search through
|
||||
search_base_dns = ["dc=grafana,dc=org"]
|
||||
|
||||
search_filter = "(uid=%s)"
|
||||
|
||||
group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
|
||||
group_search_filter_user_attribute = "uid"
|
||||
group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||
|
||||
[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=posix-admins,ou=groups,dc=grafana,dc=org"
|
||||
org_role = "Admin"
|
||||
grafana_admin = true
|
||||
|
||||
# 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"
|
||||
@@ -12,7 +12,7 @@ After adding ldif files to `prepopulate`:
|
||||
|
||||
## Enabling LDAP in Grafana
|
||||
|
||||
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:
|
||||
If you want to use users/groups with `memberOf` support Copy the ldap_dev.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block:
|
||||
|
||||
```ini
|
||||
[auth.ldap]
|
||||
@@ -21,6 +21,8 @@ config_file = conf/ldap_dev.toml
|
||||
; allow_sign_up = true
|
||||
```
|
||||
|
||||
Otherwise perform same actions for `ldap_dev_posix.toml` config.
|
||||
|
||||
## Groups & Users
|
||||
|
||||
admins
|
||||
@@ -38,3 +40,11 @@ editors
|
||||
ldap-editors
|
||||
no groups
|
||||
ldap-viewer
|
||||
|
||||
|
||||
## Groups & Users (POSIX)
|
||||
|
||||
admins
|
||||
ldap-posix-admin
|
||||
no groups
|
||||
ldap-posix
|
||||
|
||||
@@ -78,3 +78,31 @@ objectClass: inetOrgPerson
|
||||
objectClass: organizationalPerson
|
||||
sn: ldap-torkel
|
||||
cn: ldap-torkel
|
||||
|
||||
# admin for posix group (without support for memberOf attribute)
|
||||
dn: uid=ldap-posix-admin,ou=users,dc=grafana,dc=org
|
||||
mail: ldap-posix-admin@grafana.com
|
||||
userPassword: grafana
|
||||
objectclass: top
|
||||
objectclass: posixAccount
|
||||
objectclass: inetOrgPerson
|
||||
homedirectory: /home/ldap-posix-admin
|
||||
sn: ldap-posix-admin
|
||||
cn: ldap-posix-admin
|
||||
uid: ldap-posix-admin
|
||||
uidnumber: 1
|
||||
gidnumber: 1
|
||||
|
||||
# user for posix group (without support for memberOf attribute)
|
||||
dn: uid=ldap-posix,ou=users,dc=grafana,dc=org
|
||||
mail: ldap-posix@grafana.com
|
||||
userPassword: grafana
|
||||
objectclass: top
|
||||
objectclass: posixAccount
|
||||
objectclass: inetOrgPerson
|
||||
homedirectory: /home/ldap-posix
|
||||
sn: ldap-posix
|
||||
cn: ldap-posix
|
||||
uid: ldap-posix
|
||||
uidnumber: 2
|
||||
gidnumber: 2
|
||||
|
||||
@@ -23,3 +23,21 @@ objectClass: groupOfNames
|
||||
member: cn=ldap-torkel,ou=users,dc=grafana,dc=org
|
||||
member: cn=ldap-daniel,ou=users,dc=grafana,dc=org
|
||||
member: cn=ldap-leo,ou=users,dc=grafana,dc=org
|
||||
|
||||
# -- POSIX --
|
||||
|
||||
# posix admin group (without support for memberOf attribute)
|
||||
dn: cn=posix-admins,ou=groups,dc=grafana,dc=org
|
||||
cn: admins
|
||||
objectClass: top
|
||||
objectClass: posixGroup
|
||||
gidNumber: 1
|
||||
memberUid: ldap-posix-admin
|
||||
|
||||
# posix group (without support for memberOf attribute)
|
||||
dn: cn=posix,ou=groups,dc=grafana,dc=org
|
||||
cn: viewers
|
||||
objectClass: top
|
||||
objectClass: posixGroup
|
||||
gidNumber: 2
|
||||
memberUid: ldap-posix
|
||||
|
||||
@@ -60,9 +60,9 @@ aliases = ["/v1.1", "/guides/reference/admin", "/v3.1"]
|
||||
<h4>Provisioning</h4>
|
||||
<p>A guide to help you automate your Grafana setup & configuration.</p>
|
||||
</a>
|
||||
<a href="{{< relref "guides/whats-new-in-v6-2.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
||||
<h4>What's new in v6.2</h4>
|
||||
<p>Article on all the new cool features and enhancements in v6.2</p>
|
||||
<a href="{{< relref "guides/whats-new-in-v6-3.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
||||
<h4>What's new in v6.3</h4>
|
||||
<p>Article on all the new cool features and enhancements in v6.3</p>
|
||||
</a>
|
||||
<a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
|
||||
<h4>Screencasts</h4>
|
||||
|
||||
@@ -99,3 +99,18 @@ allow_sign_up = true
|
||||
allowed_organizations = github google
|
||||
```
|
||||
|
||||
### Team Sync (Enterprise only)
|
||||
|
||||
> Only available in Grafana Enterprise v6.3+
|
||||
|
||||
With Team Sync you can map your GitHub org teams to teams in Grafana so that your users will automatically be added to
|
||||
the correct teams.
|
||||
|
||||
Your GitHub teams can be referenced in two ways:
|
||||
|
||||
- `https://github.com/orgs/<org>/teams/<team name>`
|
||||
- `@<org>/<team name>`
|
||||
|
||||
Example: `@grafana/developers`
|
||||
|
||||
[Learn more about Team Sync]({{< relref "auth/enhanced_ldap.md" >}})
|
||||
|
||||
@@ -126,8 +126,6 @@ group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||
group_search_filter_user_attribute = "uid"
|
||||
```
|
||||
|
||||
Also set `member_of = "dn"` in the `[servers.attributes]` section.
|
||||
|
||||
### Group Mappings
|
||||
|
||||
In `[[servers.group_mappings]]` you can map an LDAP group to a Grafana organization and role. These will be synced every time the user logs in, with LDAP being
|
||||
|
||||
178
docs/sources/auth/saml.md
Normal file
178
docs/sources/auth/saml.md
Normal file
@@ -0,0 +1,178 @@
|
||||
+++
|
||||
title = "SAML Authentication"
|
||||
description = "Grafana SAML Authentication"
|
||||
keywords = ["grafana", "saml", "documentation", "saml-auth"]
|
||||
aliases = ["/auth/saml/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "SAML"
|
||||
parent = "authentication"
|
||||
weight = 5
|
||||
+++
|
||||
|
||||
# SAML Authentication
|
||||
|
||||
> SAML Authentication integration is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "enterprise" >}}).
|
||||
|
||||
> Only available in Grafana v6.3+
|
||||
|
||||
The SAML authentication integration allows your Grafana users to log in by
|
||||
using an external SAML Identity Provider (IdP). To enable this, Grafana becomes
|
||||
a Service Provider (SP) in the authentication flow, interacting with the IdP to
|
||||
exchange user information.
|
||||
|
||||
## Supported SAML
|
||||
|
||||
The SAML single-sign-on (SSO) standard is varied and flexible. Our implementation contains the subset of features needed to provide a smooth authentication experience into Grafana.
|
||||
|
||||
> Should you encounter any problems with our implementation, please don't hesitate to contact us.
|
||||
|
||||
At the moment of writing, Grafana supports:
|
||||
|
||||
1. From the Service Provider (SP) to the Identity Provider (IdP)
|
||||
|
||||
- `HTTP-POST` binding
|
||||
- `HTTP-Redirect` binding
|
||||
|
||||
2. From the Identity Provider (IdP) to the Service Provider (SP)
|
||||
|
||||
- `HTTP-POST` binding
|
||||
|
||||
3. In terms of security, we currently support signed and encrypted Assertions. However, signed or encrypted requests are not supported.
|
||||
|
||||
4. In terms of initiation, only SP-initiated requests are supported. There's no support for IdP-initiated request.
|
||||
|
||||
## Set up SAML Authentication
|
||||
|
||||
To use the SAML integration, you need to enable SAML in the [main config file]({{< relref "installation/configuration.md" >}}).
|
||||
|
||||
```bash
|
||||
[auth.saml]
|
||||
# Defaults to false. If true, the feature is enabled
|
||||
enabled = true
|
||||
|
||||
# Base64-encoded public X.509 certificate. Used to sign requests to the IdP
|
||||
certificate =
|
||||
|
||||
# Path to the public X.509 certificate. Used to sign requests to the IdP
|
||||
certificate_path =
|
||||
|
||||
# Base64-encoded private key. Used to decrypt assertions from the IdP
|
||||
private_key =
|
||||
|
||||
# Path to the private key. Used to decrypt assertions from the IdP
|
||||
private_key_path =
|
||||
|
||||
# Base64-encoded IdP SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata =
|
||||
|
||||
# Path to the SAML metadata XML. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata_path =
|
||||
|
||||
# URL to fetch SAML IdP metadata. Used to verify and obtain binding locations from the IdP
|
||||
idp_metadata_url =
|
||||
|
||||
# Duration, since the IdP issued a response and the SP is allowed to process it. Defaults to 90 seconds
|
||||
max_issue_delay =
|
||||
|
||||
# Duration, for how long the SP's metadata should be valid. Defaults to 48 hours
|
||||
metadata_valid_duration =
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's name
|
||||
assertion_attribute_name = displayName
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's login handle
|
||||
assertion_attribute_login = mail
|
||||
|
||||
# Friendly name or name of the attribute within the SAML assertion to use as the user's email
|
||||
assertion_attribute_email = mail
|
||||
```
|
||||
|
||||
Important to note:
|
||||
|
||||
- like any other Grafana configuration, use of [environment variables for these options is supported]({{< relref "installation/configuration.md#using-environment-variables" >}})
|
||||
- only one form of configuration option is required. Using multiple forms, e.g. both `certificate` and `certificate_path` will result in an error
|
||||
|
||||
## Grafana Configuration
|
||||
|
||||
An example working configuration example looks like:
|
||||
|
||||
```bash
|
||||
[auth.saml]
|
||||
enabled = true
|
||||
certificate_path = "/path/to/certificate.cert"
|
||||
private_key_path = "/path/to/private_key.pem"
|
||||
metadata_path = "/my/metadata.xml"
|
||||
max_issue_delay = 90s
|
||||
metadata_valid_duration = 48h
|
||||
assertion_attribute_name = displayName
|
||||
assertion_attribute_login = mail
|
||||
assertion_attribute_email = mail
|
||||
```
|
||||
|
||||
And here is a comprehensive list of the options:
|
||||
|
||||
| Setting | Required | Description | Default |
|
||||
| ----------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `enabled` | No | Whenever SAML authentication is allowed | `false` |
|
||||
| `certificate` or `certificate_path` | Yes | Base64-encoded string or Path for the SP X.509 certificate | |
|
||||
| `private_key` or `private_key_path` | Yes | Base64-encoded string or Path for the SP private key | |
|
||||
| `idp_metadata` or `idp_metadata_path` or `idp_metadata_url` | Yes | Base64-encoded string, Path or URL for the IdP SAML metadata XML | |
|
||||
| `max_issue_delay` | No | Duration, since the IdP issued a response and the SP is allowed to process it | `90s` |
|
||||
| `metadata_valid_duration` | No | Duration, for how long the SP's metadata should be valid | `48h` |
|
||||
| `assertion_attribute_name` | No | Friendly name or name of the attribute within the SAML assertion to use as the user's name | `displayName` |
|
||||
| `assertion_attribute_login` | No | Friendly name or name of the attribute within the SAML assertion to use as the user's login handle | `mail` |
|
||||
| `assertion_attribute_email` | No | Friendly name or name of the attribute within the SAML assertion to use as the user's email | `mail` |
|
||||
|
||||
### Cert and Private Key
|
||||
|
||||
The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part.
|
||||
|
||||
Grafana supports two ways of specifying both the `certificate` and `private_key`. Without a suffix (e.g. `certificate=`), the configuration assumes you've supplied the base64-encoded file contents. However, if specified with the `_path` suffix (e.g. `certificate_path=`) Grafana will treat it as a file path and attempt to read the file from the file system.
|
||||
|
||||
### IdP Metadata
|
||||
|
||||
Expanding on the above, we'll also need the public part from our IdP for message verification. The SAML IdP metadata XML tells us where and how we should exchange the user information.
|
||||
|
||||
Currently, we support three ways of specifying the IdP metadata. Without a suffix `idp_metadata=` Grafana assumes base64-encoded XML file contents, with the `_path` suffix assumes a file path and attempts to read the file from the file system and with the `_url` suffix assumes an URL and attempts to load the metadata from the given location.
|
||||
|
||||
### Max Issue Delay
|
||||
|
||||
Prevention of SAML response replay attacks and internal clock skews between the SP (Grafana), and the IdP is covered. You can set a maximum amount of time between the IdP issuing a response and the SP (Grafana) processing it.
|
||||
|
||||
The configuration options is specified as a duration e.g. `max_issue_delay = 90s` or `max_issue_delay = 1h`
|
||||
|
||||
### Metadata valid duration
|
||||
|
||||
As an SP, our metadata is likely to expire at some point, e.g. due to a certificate rotation or change of location binding. Grafana allows you to specify for how long the metadata should be valid. Leveraging the standard's `validUntil` field, you can tell consumers until when your metadata is going to be valid. The duration is computed by adding the duration to the current time.
|
||||
|
||||
The configuration option is specified as a duration e.g. `metadata_valid_duration = 48h`
|
||||
|
||||
## Identity Provider (IdP) registration
|
||||
|
||||
For the SAML integration to work correctly, you need to make the IdP aware of the SP.
|
||||
|
||||
The integration provides two key endpoints as part of Grafana:
|
||||
|
||||
- The `/saml/metadata` endpoint. Which contains the SP's metadata. You can either download and upload it manually or make the IdP request it directly from the endpoint. Some providers name it Identifier or Entity ID.
|
||||
|
||||
- The `/saml/acs` endpoint. Which is intended to receive the ACS (Assertion Customer Service) callback. Some providers name it SSO URL or Reply URL.
|
||||
|
||||
## Assertion mapping
|
||||
|
||||
During the SAML SSO authentication flow, we receive the ACS (Assertion Customer Service) callback. The callback contains all the relevant information of the user under authentication embedded in the SAML response. Grafana parses the response to create (or update) the user within its internal database.
|
||||
|
||||
For Grafana to map the user information, it looks at the individual attributes within the assertion. You can think of these attributes as Key/Value pairs (although, they contain more information than that).
|
||||
|
||||
Grafana provides configuration options that let you modify which keys to look at for these values. The data we need to create the user in Grafana is Name, Login handle, and email.
|
||||
|
||||
An example is `assertion_attribute_name = "givenName"` where Grafana looks within the assertion for an attribute with a friendly name or name of `givenName`. Both, the friendly name (e.g. `givenName`) or the name (e.g. `urn:oid:2.5.4.42`) can be used interchangeably as the value for the configuration option.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
To troubleshoot and get more log info enable saml debug logging in the [main config file]({{< relref "installation/configuration.md" >}}).
|
||||
|
||||
```bash
|
||||
[log]
|
||||
filters = saml.auth:debug
|
||||
```
|
||||
@@ -29,6 +29,10 @@ With Grafana Enterprise you can set up synchronization between LDAP Groups and T
|
||||
|
||||
Datasource permissions allow you to restrict query access to only specific Teams and Users. [Learn More]({{< relref "permissions/datasource_permissions.md" >}}).
|
||||
|
||||
### SAML Authentication
|
||||
|
||||
Enables your Grafana Enterprise users to authenticate with SAML. [Learn More]({{< relref "auth/saml.md" >}}).
|
||||
|
||||
### Premium Plugins
|
||||
|
||||
With a Grafana Enterprise license you will get access to premium plugins, including:
|
||||
|
||||
146
docs/sources/guides/whats-new-in-v6-3.md
Normal file
146
docs/sources/guides/whats-new-in-v6-3.md
Normal file
@@ -0,0 +1,146 @@
|
||||
+++
|
||||
title = "What's New in Grafana v6.3"
|
||||
description = "Feature & improvement highlights for Grafana v6.3"
|
||||
keywords = ["grafana", "new", "documentation", "6.3"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 6.3"
|
||||
identifier = "v6.3"
|
||||
parent = "whatsnew"
|
||||
weight = -14
|
||||
+++
|
||||
|
||||
# What's New in Grafana v6.3
|
||||
|
||||
For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md)
|
||||
|
||||
## Highlights
|
||||
|
||||
- New Explore features
|
||||
- [Loki Live Streaming]({{< relref "#loki-live-streaming" >}})
|
||||
- [Loki Context Queries]({{< relref "#loki-context-queries" >}})
|
||||
- [Elasticsearch Logs Support]({{< relref "#elasticsearch-logs-support" >}})
|
||||
- [InfluxDB Logs Support]({{< relref "#influxdb-logs-support" >}})
|
||||
- [Data links]({{< relref "#data-links" >}})
|
||||
- [New Time Picker]({{< relref "#new-time-picker" >}})
|
||||
- [Graph Area Gradients]({{< relref "#graph-gradients" >}}) - A new graph display option!
|
||||
- Grafana Enterprise
|
||||
- [LDAP Active Sync]({{< relref "#ldap-active-sync" >}}) - LDAP Active Sync
|
||||
- [SAML Authentication]({{< relref "#saml-authentication" >}}) - SAML Authentication
|
||||
|
||||
## Explore improvements
|
||||
|
||||
This release adds a ton of enhancements to Explore. Both in terms of new general enhancements but also in
|
||||
new data source specific features.
|
||||
|
||||
### Loki live streaming
|
||||
|
||||
For log queries using the Loki data source you can now stream logs live directly to the Explore UI.
|
||||
|
||||
### Loki context queries
|
||||
|
||||
After finding a log line through the heavy use of query filters it can then be useful to
|
||||
see the log lines surrounding the line your searched for. The `show context` feature
|
||||
allows you to view lines before and after the line of interest.
|
||||
|
||||
### Elasticsearch logs support
|
||||
|
||||
This release adds support for searching & visualizing logs stored in Elasticsearch in the Explore mode. With a special
|
||||
simplified query interface specifically designed for logs search.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/elasticsearch_explore_logs.png" max-width="600px" caption="New Time Picker" >}}
|
||||
|
||||
Please read [Using Elasticsearch in Grafana](/features/datasources/elasticsearch/#querying-logs-beta) for more detailed information on how to get started and use it.
|
||||
|
||||
### InfluxDB logs support
|
||||
|
||||
This release adds support for searching & visualizing logs stored in InfluxDB in the Explore mode. With a special
|
||||
simplified query interface specifically designed for logs search.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/influxdb_explore_logs.png" max-width="600px" caption="New Time Picker" >}}
|
||||
|
||||
Please read [Using InfluxDB in Grafana](/features/datasources/influxdb/#querying-logs-beta) for more detailed information on how to get started and use it.
|
||||
|
||||
## Data Links
|
||||
|
||||
We have simplified the UI for defining panel drilldown links (and renamed them to Panel links). We have also added a
|
||||
new type of link named `Data link`. The reason to have two different types is to make it clear how they are used
|
||||
and what variables you can use in the link. Panel links are only shown in the top left corner of
|
||||
the panel and you cannot reference series name or any data field.
|
||||
|
||||
While `Data links` are used by the actual visualization and can reference data fields.
|
||||
|
||||
Example:
|
||||
```url
|
||||
http://my-grafana.com/d/bPCI6VSZz/other-dashboard?var-server=${__series_name}
|
||||
```
|
||||
|
||||
You have access to these variables:
|
||||
|
||||
Name | Description
|
||||
------------ | -------------
|
||||
*${__series_name}* | The name of the time series (or table)
|
||||
*${__value_time}* | The time of the point your clicking on (in millisecond epoch)
|
||||
*${__url_time_range}* | Interpolates as the full time range (i.e. from=21312323412&to=21312312312)
|
||||
*${__all_variables}* | Adds all current variables (and current values) to the url
|
||||
|
||||
You can then click on point in the Graph.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/graph_datalink.png" max-width="400px" caption="New Time Picker" >}}
|
||||
|
||||
For now only the Graph panel supports `Data links` but we hope to add these to many visualizations.
|
||||
|
||||
## New Time Picker
|
||||
|
||||
The time picker has been re-designed and with a more basic design that makes accessing quick ranges more easy.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/time_picker.png" max-width="400px" caption="New Time Picker" >}}
|
||||
|
||||
## Graph Gradients
|
||||
|
||||
Want more eye candy in your graphs? Then the fill gradient option might be for you! Works really well for
|
||||
graphs with only a single series.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/graph_gradient_area.jpeg" max-width="800px" caption="Graph Gradient Area" >}}
|
||||
|
||||
Looks really nice in light theme as well.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v63/graph_gradients_white.png" max-width="800px" caption="Graph Gradient Area" >}}
|
||||
|
||||
## Grafana Enterprise
|
||||
|
||||
Substantial refactoring and improvements to the external auth systems has gone in to this release making the features
|
||||
listed below possible as well as laying a foundation for future enhancements.
|
||||
|
||||
### LDAP Active Sync
|
||||
|
||||
This is a new Enterprise feature that enables background syncing of user information, org role and teams memberships.
|
||||
This syncing is otherwise only done at login time. With this feature you can schedule how often this user synchronization should
|
||||
occur.
|
||||
|
||||
For example, lets say a user is removed from an LDAP group. In previous versions of Grafana an admin would have to
|
||||
wait for the user to logout or the session to expire for the Grafana permissions to update, a process that can take days.
|
||||
|
||||
With active sync the user would be automatically removed from the corresponding team in Grafana or even logged out and disabled if no longer
|
||||
belonging to an LDAP group that gives them access to Grafana.
|
||||
|
||||
[Read more](/auth/enhanced_ldap/#active-ldap-synchronization)
|
||||
|
||||
### SAML Authentication
|
||||
|
||||
Built-in support for SAML is now available in Grafana Enterprise.
|
||||
|
||||
[See docs]({{< relref "auth/saml.md" >}})
|
||||
|
||||
### Team Sync for GitHub OAuth
|
||||
|
||||
When setting up OAuth with GitHub it's now possible to sync GitHub teams with Teams in Grafana.
|
||||
|
||||
[See docs]({{< relref "auth/github.md" >}})
|
||||
|
||||
### Team Sync for Auth Proxy
|
||||
|
||||
We've added support for enriching the Auth Proxy headers with Teams information, which makes it possible
|
||||
to use Team Sync with Auth Proxy.
|
||||
|
||||
[See docs](/auth/auth-proxy/#auth-proxy-authentication)
|
||||
@@ -37,15 +37,11 @@ export class ConfigCtrl {
|
||||
|
||||
postUpdate() {
|
||||
if (!this.appModel.enabled) {
|
||||
return this.$q.resolve();
|
||||
return;
|
||||
}
|
||||
return this.appEditCtrl.importDashboards().then(() => {
|
||||
this.enabled = true;
|
||||
return {
|
||||
url: "plugins/raintank-kubernetes-app/page/clusters",
|
||||
message: "Kubernetes App enabled!"
|
||||
};
|
||||
});
|
||||
|
||||
// TODO, whatever you want
|
||||
console.log('Post Update:', this);
|
||||
}
|
||||
}
|
||||
ConfigCtrl.templateUrl = 'components/config/config.html';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[
|
||||
{ "version": "v6.2", "path": "/", "archived": false, "current": true },
|
||||
{ "version": "v6.3", "path": "/", "archived": false, "current": true },
|
||||
{ "version": "v6.2", "path": "/v6.2", "archived": true },
|
||||
{ "version": "v6.1", "path": "/v6.1", "archived": true },
|
||||
{ "version": "v6.0", "path": "/v6.0", "archived": true },
|
||||
{ "version": "v5.4", "path": "/v5.4", "archived": true },
|
||||
|
||||
6
lerna.json
Normal file
6
lerna.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "6.3.0-alpha.36"
|
||||
}
|
||||
13
package.json
13
package.json
@@ -5,7 +5,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "6.3.0-pre",
|
||||
"version": "6.3.0-beta4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
@@ -79,6 +79,7 @@
|
||||
"husky": "1.3.1",
|
||||
"jest": "24.8.0",
|
||||
"jest-date-mock": "1.0.7",
|
||||
"lerna": "^3.15.0",
|
||||
"lint-staged": "8.1.5",
|
||||
"load-grunt-tasks": "3.5.2",
|
||||
"mini-css-extract-plugin": "0.5.0",
|
||||
@@ -143,13 +144,11 @@
|
||||
"storybook:build": "cd packages/grafana-ui && yarn storybook:build",
|
||||
"prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"",
|
||||
"prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write",
|
||||
"gui:tslint": "tslint -c ./packages/grafana-ui/tslint.json --project ./packages/grafana-ui/tsconfig.json",
|
||||
"gui:build": "grafana-toolkit gui:build",
|
||||
"gui:releasePrepare": "grafana-toolkit gui:release",
|
||||
"gui:publish": "cd packages/grafana-ui/dist && npm publish --access public",
|
||||
"gui:release": "grafana-toolkit gui:release -p --createVersionCommit",
|
||||
"precommit": "grafana-toolkit precommit",
|
||||
"themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts"
|
||||
"themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts",
|
||||
"packages:prepare": "lerna run clean && npm run test && lerna version --tag-version-prefix=\"packages@\" -m \"Packages: publish %s\" --no-push",
|
||||
"packages:build": "lerna run clean && lerna run build",
|
||||
"packages:publish": "lerna publish from-package --contents dist --tag-version-prefix=\"packages@\" --dist-tag next"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
||||
15
packages/README.md
Normal file
15
packages/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Grafana frontend packages
|
||||
|
||||
## Releasing new version
|
||||
We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases
|
||||
|
||||
### Manual release
|
||||
1. Run `packages:prepare` script from root directory. This will perform cleanup, run all tests and bump version for all packages. Also, it will create `@packages@[version]` tag and version bump commit with `Packages: publish [version]` message.
|
||||
2. Run `packages:build` script that will prepare distribution packages.
|
||||
3. Run `packages:publish` to publish new versions
|
||||
- add `--dist-tag next` to publish under `next` tag
|
||||
4. Push version commit
|
||||
|
||||
### Building individual packages
|
||||
To build induvidual packages run `grafana-toolkit package:build --scope=<ui|toolkit|runtime|data>`
|
||||
|
||||
3
packages/grafana-data/CHANGELOG.md
Normal file
3
packages/grafana-data/CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# (2019-07-08)
|
||||
First public release
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana/data",
|
||||
"version": "6.3.0-alpha.0",
|
||||
"version": "6.3.0-alpha.36",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
@@ -10,11 +10,12 @@
|
||||
"tslint": "tslint -c tslint.json --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf ./dist ./compiled",
|
||||
"build": "rollup -c rollup.config.ts"
|
||||
"bundle": "rollup -c rollup.config.ts",
|
||||
"build": "grafana-toolkit package:build --scope=data",
|
||||
"postpublish": "npm run clean"
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/jest": "23.3.14",
|
||||
"@types/jquery": "1.10.35",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Threshold } from './threshold';
|
||||
import { ValueMapping } from './valueMapping';
|
||||
|
||||
export enum LoadingState {
|
||||
NotStarted = 'NotStarted',
|
||||
Loading = 'Loading',
|
||||
@@ -49,6 +52,12 @@ export interface Field {
|
||||
decimals?: number | null; // Significant digits (for display)
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
|
||||
// Convert input values into a display value
|
||||
mappings?: ValueMapping[];
|
||||
|
||||
// Must be sorted by 'value', first value is always -Infinity
|
||||
thresholds?: Threshold[];
|
||||
}
|
||||
|
||||
export interface Labels {
|
||||
@@ -97,6 +106,9 @@ export interface AnnotationEvent {
|
||||
dashboardId?: number;
|
||||
panelId?: number;
|
||||
userId?: number;
|
||||
login?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
time?: number;
|
||||
timeEnd?: number;
|
||||
isRegion?: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './data';
|
||||
export * from './dataLink';
|
||||
export * from './logs';
|
||||
export * from './navModel';
|
||||
export * from './select';
|
||||
export * from './time';
|
||||
export * from './threshold';
|
||||
export * from './utils';
|
||||
|
||||
10
packages/grafana-data/src/types/select.ts
Normal file
10
packages/grafana-data/src/types/select.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Used in select elements
|
||||
*/
|
||||
export interface SelectableValue<T = any> {
|
||||
label?: string;
|
||||
value?: T;
|
||||
imgUrl?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export interface Threshold {
|
||||
index: number;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@ import { TimeZone } from '../types';
|
||||
|
||||
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
|
||||
|
||||
export function isMathString(text: string | DateTime | Date): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses different types input to a moment instance. There is a specific formatting language that can be used
|
||||
* if text arg is string. See unit tests for examples.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { getFieldReducers, ReducerID, reduceField } from './index';
|
||||
import { fieldReducers, ReducerID, reduceField } from './fieldReducer';
|
||||
|
||||
import _ from 'lodash';
|
||||
import { DataFrame } from '../types/data';
|
||||
|
||||
/**
|
||||
* Run a reducer and get back the value
|
||||
*/
|
||||
function reduce(series: DataFrame, fieldIndex: number, id: string): any {
|
||||
return reduceField({ series, fieldIndex, reducers: [id] })[id];
|
||||
}
|
||||
|
||||
describe('Stats Calculators', () => {
|
||||
const basicTable = {
|
||||
@@ -9,29 +17,16 @@ describe('Stats Calculators', () => {
|
||||
};
|
||||
|
||||
it('should load all standard stats', () => {
|
||||
const names = [
|
||||
ReducerID.sum,
|
||||
ReducerID.max,
|
||||
ReducerID.min,
|
||||
ReducerID.logmin,
|
||||
ReducerID.mean,
|
||||
ReducerID.last,
|
||||
ReducerID.first,
|
||||
ReducerID.count,
|
||||
ReducerID.range,
|
||||
ReducerID.diff,
|
||||
ReducerID.step,
|
||||
ReducerID.delta,
|
||||
// ReducerID.allIsZero,
|
||||
// ReducerID.allIsNull,
|
||||
];
|
||||
const stats = getFieldReducers(names);
|
||||
expect(stats.length).toBe(names.length);
|
||||
for (const id of Object.keys(ReducerID)) {
|
||||
const reducer = fieldReducers.getIfExists(id);
|
||||
const found = reducer ? reducer.id : '<NOT FOUND>';
|
||||
expect(found).toEqual(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail to load unknown stats', () => {
|
||||
const names = ['not a stat', ReducerID.max, ReducerID.min, 'also not a stat'];
|
||||
const stats = getFieldReducers(names);
|
||||
const stats = fieldReducers.list(names);
|
||||
expect(stats.length).toBe(2);
|
||||
|
||||
const found = stats.map(v => v.id);
|
||||
@@ -91,4 +86,86 @@ describe('Stats Calculators', () => {
|
||||
expect(stats.step).toEqual(100);
|
||||
expect(stats.delta).toEqual(300);
|
||||
});
|
||||
|
||||
it('consistenly check allIsNull/allIsZero', () => {
|
||||
const empty = {
|
||||
fields: [{ name: 'A' }],
|
||||
rows: [],
|
||||
};
|
||||
const allNull = ({
|
||||
fields: [{ name: 'A' }],
|
||||
rows: [null, null, null, null],
|
||||
} as unknown) as DataFrame;
|
||||
const allNull2 = {
|
||||
fields: [{ name: 'A' }],
|
||||
rows: [[null], [null], [null], [null]],
|
||||
};
|
||||
const allZero = {
|
||||
fields: [{ name: 'A' }],
|
||||
rows: [[0], [0], [0], [0]],
|
||||
};
|
||||
|
||||
expect(reduce(empty, 0, ReducerID.allIsNull)).toEqual(true);
|
||||
expect(reduce(allNull, 0, ReducerID.allIsNull)).toEqual(true);
|
||||
expect(reduce(allNull2, 0, ReducerID.allIsNull)).toEqual(true);
|
||||
|
||||
expect(reduce(empty, 0, ReducerID.allIsZero)).toEqual(false);
|
||||
expect(reduce(allNull, 0, ReducerID.allIsZero)).toEqual(false);
|
||||
expect(reduce(allNull2, 0, ReducerID.allIsZero)).toEqual(false);
|
||||
expect(reduce(allZero, 0, ReducerID.allIsZero)).toEqual(true);
|
||||
});
|
||||
|
||||
it('consistent results for first/last value with null', () => {
|
||||
const info = [
|
||||
{
|
||||
rows: [[null], [200], [null]], // first/last value is null
|
||||
result: 200,
|
||||
},
|
||||
{
|
||||
rows: [[null], [null], [null]], // All null
|
||||
result: undefined,
|
||||
},
|
||||
{
|
||||
rows: [], // Empty row
|
||||
result: undefined,
|
||||
},
|
||||
];
|
||||
const fields = [{ name: 'A' }];
|
||||
|
||||
const stats = reduceField({
|
||||
series: { rows: info[0].rows, fields },
|
||||
fieldIndex: 0,
|
||||
reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull], // uses standard path
|
||||
});
|
||||
expect(stats[ReducerID.first]).toEqual(null);
|
||||
expect(stats[ReducerID.last]).toEqual(null);
|
||||
expect(stats[ReducerID.firstNotNull]).toEqual(200);
|
||||
expect(stats[ReducerID.lastNotNull]).toEqual(200);
|
||||
|
||||
const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull];
|
||||
for (const input of info) {
|
||||
for (const reducer of reducers) {
|
||||
const v1 = reduceField({
|
||||
series: { rows: input.rows, fields },
|
||||
fieldIndex: 0,
|
||||
reducers: [reducer, ReducerID.mean], // uses standard path
|
||||
})[reducer];
|
||||
|
||||
const v2 = reduceField({
|
||||
series: { rows: input.rows, fields },
|
||||
fieldIndex: 0,
|
||||
reducers: [reducer], // uses optimized path
|
||||
})[reducer];
|
||||
|
||||
if (v1 !== v2 || v1 !== input.result) {
|
||||
const msg =
|
||||
`Invalid ${reducer} result for: ` +
|
||||
input.rows.join(', ') +
|
||||
` Expected: ${input.result}` + // configured
|
||||
` Recieved: Multiple: ${v1}, Single: ${v2}`;
|
||||
expect(msg).toEqual(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Libraries
|
||||
import isNumber from 'lodash/isNumber';
|
||||
|
||||
import { DataFrame, NullValueMode } from '../types/index';
|
||||
import { DataFrame, NullValueMode } from '../types';
|
||||
import { Registry, RegistryItem } from './registry';
|
||||
|
||||
export enum ReducerID {
|
||||
sum = 'sum',
|
||||
@@ -17,6 +18,9 @@ export enum ReducerID {
|
||||
delta = 'delta',
|
||||
step = 'step',
|
||||
|
||||
firstNotNull = 'firstNotNull',
|
||||
lastNotNull = 'lastNotNull',
|
||||
|
||||
changeCount = 'changeCount',
|
||||
distinctCount = 'distinctCount',
|
||||
|
||||
@@ -31,38 +35,13 @@ export interface FieldCalcs {
|
||||
// Internal function
|
||||
type FieldReducer = (data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs;
|
||||
|
||||
export interface FieldReducerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
alias?: string; // optional secondary key. 'avg' vs 'mean', 'total' vs 'sum'
|
||||
|
||||
export interface FieldReducerInfo extends RegistryItem {
|
||||
// Internal details
|
||||
emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero
|
||||
standard: boolean; // The most common stats can all be calculated in a single pass
|
||||
reduce?: FieldReducer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ids list of stat names or null to get all of them
|
||||
*/
|
||||
export function getFieldReducers(ids?: string[]): FieldReducerInfo[] {
|
||||
if (ids === null || ids === undefined) {
|
||||
if (!hasBuiltIndex) {
|
||||
getById(ReducerID.mean);
|
||||
}
|
||||
return listOfStats;
|
||||
}
|
||||
|
||||
return ids.reduce((list, id) => {
|
||||
const stat = getById(id);
|
||||
if (stat) {
|
||||
list.push(stat);
|
||||
}
|
||||
return list;
|
||||
}, new Array<FieldReducerInfo>());
|
||||
}
|
||||
|
||||
interface ReduceFieldOptions {
|
||||
series: DataFrame;
|
||||
fieldIndex: number;
|
||||
@@ -80,7 +59,7 @@ export function reduceField(options: ReduceFieldOptions): FieldCalcs {
|
||||
return {};
|
||||
}
|
||||
|
||||
const queue = getFieldReducers(reducers);
|
||||
const queue = fieldReducers.list(reducers);
|
||||
|
||||
// Return early for empty series
|
||||
// This lets the concrete implementations assume at least one row
|
||||
@@ -119,108 +98,107 @@ export function reduceField(options: ReduceFieldOptions): FieldCalcs {
|
||||
//
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
// private registry of all stats
|
||||
interface TableStatIndex {
|
||||
[id: string]: FieldReducerInfo;
|
||||
}
|
||||
|
||||
const listOfStats: FieldReducerInfo[] = [];
|
||||
const index: TableStatIndex = {};
|
||||
let hasBuiltIndex = false;
|
||||
|
||||
function getById(id: string): FieldReducerInfo | undefined {
|
||||
if (!hasBuiltIndex) {
|
||||
[
|
||||
{
|
||||
id: ReducerID.last,
|
||||
name: 'Last',
|
||||
description: 'Last Value (current)',
|
||||
standard: true,
|
||||
alias: 'current',
|
||||
reduce: calculateLast,
|
||||
},
|
||||
{ id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst },
|
||||
{ id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true },
|
||||
{ id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true },
|
||||
{ id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
|
||||
{
|
||||
id: ReducerID.sum,
|
||||
name: 'Total',
|
||||
description: 'The sum of all values',
|
||||
emptyInputResult: 0,
|
||||
standard: true,
|
||||
alias: 'total',
|
||||
},
|
||||
{
|
||||
id: ReducerID.count,
|
||||
name: 'Count',
|
||||
description: 'Number of values in response',
|
||||
emptyInputResult: 0,
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.range,
|
||||
name: 'Range',
|
||||
description: 'Difference between minimum and maximum values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.delta,
|
||||
name: 'Delta',
|
||||
description: 'Cumulative change in value',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.step,
|
||||
name: 'Step',
|
||||
description: 'Minimum interval between values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.diff,
|
||||
name: 'Difference',
|
||||
description: 'Difference between first and last values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.logmin,
|
||||
name: 'Min (above zero)',
|
||||
description: 'Used for log min scale',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.changeCount,
|
||||
name: 'Change Count',
|
||||
description: 'Number of times the value changes',
|
||||
standard: false,
|
||||
reduce: calculateChangeCount,
|
||||
},
|
||||
{
|
||||
id: ReducerID.distinctCount,
|
||||
name: 'Distinct Count',
|
||||
description: 'Number of distinct values',
|
||||
standard: false,
|
||||
reduce: calculateDistinctCount,
|
||||
},
|
||||
].forEach(info => {
|
||||
const { id, alias } = info;
|
||||
if (index.hasOwnProperty(id)) {
|
||||
console.warn('Duplicate Stat', id, info, index);
|
||||
}
|
||||
index[id] = info;
|
||||
if (alias) {
|
||||
if (index.hasOwnProperty(alias)) {
|
||||
console.warn('Duplicate Stat (alias)', alias, info, index);
|
||||
}
|
||||
index[alias] = info;
|
||||
}
|
||||
listOfStats.push(info);
|
||||
});
|
||||
hasBuiltIndex = true;
|
||||
}
|
||||
|
||||
return index[id];
|
||||
}
|
||||
export const fieldReducers = new Registry<FieldReducerInfo>(() => [
|
||||
{
|
||||
id: ReducerID.lastNotNull,
|
||||
name: 'Last (not null)',
|
||||
description: 'Last non-null value',
|
||||
standard: true,
|
||||
alias: 'current',
|
||||
reduce: calculateLastNotNull,
|
||||
},
|
||||
{
|
||||
id: ReducerID.last,
|
||||
name: 'Last',
|
||||
description: 'Last Value',
|
||||
standard: true,
|
||||
reduce: calculateLast,
|
||||
},
|
||||
{ id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst },
|
||||
{
|
||||
id: ReducerID.firstNotNull,
|
||||
name: 'First (not null)',
|
||||
description: 'First non-null value',
|
||||
standard: true,
|
||||
reduce: calculateFirstNotNull,
|
||||
},
|
||||
{ id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true },
|
||||
{ id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true },
|
||||
{ id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' },
|
||||
{
|
||||
id: ReducerID.sum,
|
||||
name: 'Total',
|
||||
description: 'The sum of all values',
|
||||
emptyInputResult: 0,
|
||||
standard: true,
|
||||
alias: 'total',
|
||||
},
|
||||
{
|
||||
id: ReducerID.count,
|
||||
name: 'Count',
|
||||
description: 'Number of values in response',
|
||||
emptyInputResult: 0,
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.range,
|
||||
name: 'Range',
|
||||
description: 'Difference between minimum and maximum values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.delta,
|
||||
name: 'Delta',
|
||||
description: 'Cumulative change in value',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.step,
|
||||
name: 'Step',
|
||||
description: 'Minimum interval between values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.diff,
|
||||
name: 'Difference',
|
||||
description: 'Difference between first and last values',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.logmin,
|
||||
name: 'Min (above zero)',
|
||||
description: 'Used for log min scale',
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.allIsZero,
|
||||
name: 'All Zeros',
|
||||
description: 'All values are zero',
|
||||
emptyInputResult: false,
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.allIsNull,
|
||||
name: 'All Nulls',
|
||||
description: 'All values are null',
|
||||
emptyInputResult: true,
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
id: ReducerID.changeCount,
|
||||
name: 'Change Count',
|
||||
description: 'Number of times the value changes',
|
||||
standard: false,
|
||||
reduce: calculateChangeCount,
|
||||
},
|
||||
{
|
||||
id: ReducerID.distinctCount,
|
||||
name: 'Distinct Count',
|
||||
description: 'Number of distinct values',
|
||||
standard: false,
|
||||
reduce: calculateDistinctCount,
|
||||
},
|
||||
]);
|
||||
|
||||
function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
|
||||
const calcs = {
|
||||
@@ -231,10 +209,12 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
mean: null,
|
||||
last: null,
|
||||
first: null,
|
||||
lastNotNull: undefined,
|
||||
firstNotNull: undefined,
|
||||
count: 0,
|
||||
nonNullCount: 0,
|
||||
allIsNull: true,
|
||||
allIsZero: false,
|
||||
allIsZero: true,
|
||||
range: null,
|
||||
diff: null,
|
||||
delta: 0,
|
||||
@@ -245,7 +225,11 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
} as FieldCalcs;
|
||||
|
||||
for (let i = 0; i < data.rows.length; i++) {
|
||||
let currentValue = data.rows[i][fieldIndex];
|
||||
let currentValue = data.rows[i] ? data.rows[i][fieldIndex] : null;
|
||||
if (i === 0) {
|
||||
calcs.first = currentValue;
|
||||
}
|
||||
calcs.last = currentValue;
|
||||
|
||||
if (currentValue === null) {
|
||||
if (ignoreNulls) {
|
||||
@@ -257,9 +241,9 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
}
|
||||
|
||||
if (currentValue !== null) {
|
||||
const isFirst = calcs.first === null;
|
||||
const isFirst = calcs.firstNotNull === undefined;
|
||||
if (isFirst) {
|
||||
calcs.first = currentValue;
|
||||
calcs.firstNotNull = currentValue;
|
||||
}
|
||||
|
||||
if (isNumber(currentValue)) {
|
||||
@@ -268,12 +252,12 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
calcs.nonNullCount++;
|
||||
|
||||
if (!isFirst) {
|
||||
const step = currentValue - calcs.last!;
|
||||
const step = currentValue - calcs.lastNotNull!;
|
||||
if (calcs.step > step) {
|
||||
calcs.step = step; // the minimum interval
|
||||
}
|
||||
|
||||
if (calcs.last! > currentValue) {
|
||||
if (calcs.lastNotNull! > currentValue) {
|
||||
// counter reset
|
||||
calcs.previousDeltaUp = false;
|
||||
if (i === data.rows.length - 1) {
|
||||
@@ -307,7 +291,7 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
calcs.allIsZero = false;
|
||||
}
|
||||
|
||||
calcs.last = currentValue;
|
||||
calcs.lastNotNull = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,14 +311,16 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
|
||||
calcs.mean = calcs.sum! / calcs.nonNullCount;
|
||||
}
|
||||
|
||||
if (calcs.allIsNull) {
|
||||
calcs.allIsZero = false;
|
||||
}
|
||||
|
||||
if (calcs.max !== null && calcs.min !== null) {
|
||||
calcs.range = calcs.max - calcs.min;
|
||||
}
|
||||
|
||||
if (calcs.first !== null && calcs.last !== null) {
|
||||
if (isNumber(calcs.first) && isNumber(calcs.last)) {
|
||||
calcs.diff = calcs.last - calcs.first;
|
||||
}
|
||||
if (isNumber(calcs.firstNotNull) && isNumber(calcs.lastNotNull)) {
|
||||
calcs.diff = calcs.lastNotNull - calcs.firstNotNull;
|
||||
}
|
||||
|
||||
return calcs;
|
||||
@@ -344,10 +330,41 @@ function calculateFirst(data: DataFrame, fieldIndex: number, ignoreNulls: boolea
|
||||
return { first: data.rows[0][fieldIndex] };
|
||||
}
|
||||
|
||||
function calculateFirstNotNull(
|
||||
data: DataFrame,
|
||||
fieldIndex: number,
|
||||
ignoreNulls: boolean,
|
||||
nullAsZero: boolean
|
||||
): FieldCalcs {
|
||||
for (let idx = 0; idx < data.rows.length; idx++) {
|
||||
const v = data.rows[idx][fieldIndex];
|
||||
if (v != null) {
|
||||
return { firstNotNull: v };
|
||||
}
|
||||
}
|
||||
return { firstNotNull: undefined };
|
||||
}
|
||||
|
||||
function calculateLast(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
|
||||
return { last: data.rows[data.rows.length - 1][fieldIndex] };
|
||||
}
|
||||
|
||||
function calculateLastNotNull(
|
||||
data: DataFrame,
|
||||
fieldIndex: number,
|
||||
ignoreNulls: boolean,
|
||||
nullAsZero: boolean
|
||||
): FieldCalcs {
|
||||
let idx = data.rows.length - 1;
|
||||
while (idx >= 0) {
|
||||
const v = data.rows[idx--][fieldIndex];
|
||||
if (v != null) {
|
||||
return { lastNotNull: v };
|
||||
}
|
||||
}
|
||||
return { lastNotNull: undefined };
|
||||
}
|
||||
|
||||
function calculateChangeCount(
|
||||
data: DataFrame,
|
||||
fieldIndex: number,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './string';
|
||||
export * from './registry';
|
||||
export * from './markdown';
|
||||
export * from './processDataFrame';
|
||||
export * from './csv';
|
||||
|
||||
134
packages/grafana-data/src/utils/registry.ts
Normal file
134
packages/grafana-data/src/utils/registry.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { SelectableValue } from '../types/select';
|
||||
|
||||
export interface RegistryItem {
|
||||
id: string; // Unique Key -- saved in configs
|
||||
name: string; // Display Name, can change without breaking configs
|
||||
description: string;
|
||||
aliasIds?: string[]; // when the ID changes, we may want backwards compatibility ('current' => 'last')
|
||||
|
||||
/**
|
||||
* Some extensions should not be user selectable
|
||||
* like: 'all' and 'any' matchers;
|
||||
*/
|
||||
excludeFromPicker?: boolean;
|
||||
}
|
||||
|
||||
interface RegistrySelectInfo {
|
||||
options: Array<SelectableValue<string>>;
|
||||
current: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
export class Registry<T extends RegistryItem> {
|
||||
private ordered: T[] = [];
|
||||
private byId = new Map<string, T>();
|
||||
private initalized = false;
|
||||
|
||||
constructor(private init?: () => T[]) {}
|
||||
|
||||
getIfExists(id: string | undefined): T | undefined {
|
||||
if (!this.initalized) {
|
||||
if (this.init) {
|
||||
for (const ext of this.init()) {
|
||||
this.register(ext);
|
||||
}
|
||||
}
|
||||
this.sort();
|
||||
this.initalized = true;
|
||||
}
|
||||
if (id) {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get(id: string): T {
|
||||
const v = this.getIfExists(id);
|
||||
if (!v) {
|
||||
throw new Error('Undefined: ' + id);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
selectOptions(current?: string[], filter?: (ext: T) => boolean): RegistrySelectInfo {
|
||||
if (!this.initalized) {
|
||||
this.getIfExists('xxx'); // will trigger init
|
||||
}
|
||||
|
||||
const select = {
|
||||
options: [],
|
||||
current: [],
|
||||
} as RegistrySelectInfo;
|
||||
|
||||
const currentIds: any = {};
|
||||
if (current) {
|
||||
for (const id of current) {
|
||||
currentIds[id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of this.ordered) {
|
||||
if (ext.excludeFromPicker) {
|
||||
continue;
|
||||
}
|
||||
if (filter && !filter(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = {
|
||||
value: ext.id,
|
||||
label: ext.name,
|
||||
description: ext.description,
|
||||
};
|
||||
|
||||
select.options.push(option);
|
||||
if (currentIds[ext.id]) {
|
||||
select.current.push(option);
|
||||
}
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of values by ID, or all values if not specified
|
||||
*/
|
||||
list(ids?: any[]): T[] {
|
||||
if (ids) {
|
||||
const found: T[] = [];
|
||||
for (const id of ids) {
|
||||
const v = this.getIfExists(id);
|
||||
if (v) {
|
||||
found.push(v);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
if (!this.initalized) {
|
||||
this.getIfExists('xxx'); // will trigger init
|
||||
}
|
||||
return [...this.ordered]; // copy of everythign just in case
|
||||
}
|
||||
|
||||
register(ext: T) {
|
||||
if (this.byId.has(ext.id)) {
|
||||
throw new Error('Duplicate Key:' + ext.id);
|
||||
}
|
||||
this.byId.set(ext.id, ext);
|
||||
this.ordered.push(ext);
|
||||
|
||||
if (ext.aliasIds) {
|
||||
for (const alias of ext.aliasIds) {
|
||||
if (!this.byId.has(alias)) {
|
||||
this.byId.set(alias, ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.initalized) {
|
||||
this.sort();
|
||||
}
|
||||
}
|
||||
|
||||
private sort() {
|
||||
// TODO sort the list
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Threshold } from '../types';
|
||||
|
||||
export function getThresholdForValue(
|
||||
thresholds: Threshold[],
|
||||
value: number | null | string | undefined
|
||||
): Threshold | null {
|
||||
if (thresholds.length === 1) {
|
||||
return thresholds[0];
|
||||
export function getActiveThreshold(value: number, thresholds: Threshold[]): Threshold {
|
||||
let active = thresholds[0];
|
||||
for (const threshold of thresholds) {
|
||||
if (value >= threshold.value) {
|
||||
active = threshold;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return atThreshold;
|
||||
}
|
||||
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1: Threshold, t2: Threshold) => t2.value - t1.value)[0];
|
||||
return nearestThreshold;
|
||||
}
|
||||
|
||||
return null;
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the thresholds
|
||||
*/
|
||||
export function sortThresholds(thresholds: Threshold[]) {
|
||||
return thresholds.sort((t1, t2) => {
|
||||
return t1.value - t2.value;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "../../public/app/types/jquery/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"rootDirs": ["."],
|
||||
"module": "esnext",
|
||||
"outDir": "compiled",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"typeRoots": ["./node_modules/@types", "types"],
|
||||
"skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors,
|
||||
"removeComments": false
|
||||
"declarationDir": "dist",
|
||||
"outDir": "compiled"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/grafana-runtime/CHANGELOG.md
Normal file
3
packages/grafana-runtime/CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# (2019-07-08)
|
||||
First public release
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana/runtime",
|
||||
"version": "6.3.0-alpha.0",
|
||||
"version": "6.3.0-alpha.36",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
@@ -12,7 +12,9 @@
|
||||
"tslint": "tslint -c tslint.json --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf ./dist ./compiled",
|
||||
"build": "rollup -c rollup.config.ts"
|
||||
"bundle": "rollup -c rollup.config.ts",
|
||||
"build": "grafana-toolkit package:build --scope=runtime",
|
||||
"postpublish": "npm run clean"
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -20,7 +20,7 @@ const buildCjsPackage = ({ env }) => {
|
||||
globals: {},
|
||||
},
|
||||
],
|
||||
external: ['lodash'], // Use Lodash from grafana
|
||||
external: ['lodash', '@grafana/ui', '@grafana/data'], // Use Lodash from grafana
|
||||
plugins: [
|
||||
commonjs({
|
||||
include: /node_modules/,
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "../../public/app/types/jquery/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"rootDirs": ["."],
|
||||
"module": "esnext",
|
||||
"outDir": "compiled",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"typeRoots": ["./node_modules/@types", "types"],
|
||||
"skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors,
|
||||
"removeComments": false
|
||||
"declarationDir": "dist",
|
||||
"outDir": "compiled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ Adidtionaly, you can also provide additional Jest config via package.json file.
|
||||
|
||||
|
||||
## Working with CSS & static assets
|
||||
We support pure css, SASS and CSS in JS approach (via Emotion). All static assets referenced in your code (i.e. images) should be placed under `src/static` directory and referenced using relative paths.
|
||||
We support pure css, SASS and CSS in JS approach (via Emotion).
|
||||
|
||||
1. Single css/sass file
|
||||
Create your css/sass file and import it in your plugin entry point (typically module.ts):
|
||||
@@ -101,6 +101,8 @@ If you want to provide different stylesheets for dark/light theme, create `dark.
|
||||
|
||||
TODO: add note about loadPluginCss
|
||||
|
||||
Note that static files (png, svg, json, html) are all copied to dist directory when the plugin is bundled. Relative paths to those files does not change.
|
||||
|
||||
3. Emotion
|
||||
Starting from Grafana 6.2 our suggested way of styling plugins is by using [Emotion](https://emotion.sh). It's a css-in-js library that we use internaly at Grafana. The biggest advantage of using Emotion is that you will get access to Grafana Theme variables.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "6.3.0-alpha.2",
|
||||
"version": "6.3.0-alpha.36",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
@@ -14,7 +14,9 @@
|
||||
"tslint": "tslint -c tslint.json --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"precommit": "npm run tslint & npm run typecheck",
|
||||
"clean": "rimraf ./dist ./compiled"
|
||||
"clean": "rimraf ./dist ./compiled",
|
||||
"build": "grafana-toolkit toolkit:build",
|
||||
"postpublish": "npm run clean"
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@types/webpack": "4.4.34",
|
||||
"axios": "0.19.0",
|
||||
"babel-loader": "8.0.6",
|
||||
"babel-plugin-angularjs-annotate": "0.10.0",
|
||||
"chalk": "^2.4.2",
|
||||
"commander": "^2.20.0",
|
||||
"concurrently": "4.1.0",
|
||||
@@ -46,7 +49,6 @@
|
||||
"jest-coverage-badges": "^1.1.2",
|
||||
"lodash": "4.17.11",
|
||||
"mini-css-extract-plugin": "^0.7.0",
|
||||
"ng-annotate-webpack-plugin": "^0.3.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"ora": "^3.4.0",
|
||||
|
||||
@@ -3,8 +3,6 @@ import program from 'commander';
|
||||
import { execTask } from './utils/execTask';
|
||||
import chalk from 'chalk';
|
||||
import { startTask } from './tasks/core.start';
|
||||
import { buildTask } from './tasks/grafanaui.build';
|
||||
import { releaseTask } from './tasks/grafanaui.release';
|
||||
import { changelogTask } from './tasks/changelog';
|
||||
import { cherryPickTask } from './tasks/cherrypick';
|
||||
import { precommitTask } from './tasks/precommit';
|
||||
@@ -16,6 +14,7 @@ import { searchTestDataSetupTask } from './tasks/searchTestDataSetup';
|
||||
import { closeMilestoneTask } from './tasks/closeMilestone';
|
||||
import { pluginDevTask } from './tasks/plugin.dev';
|
||||
import { pluginCITask } from './tasks/plugin.ci';
|
||||
import { buildPackageTask } from './tasks/package.build';
|
||||
|
||||
export const run = (includeInternalScripts = false) => {
|
||||
if (includeInternalScripts) {
|
||||
@@ -33,24 +32,12 @@ export const run = (includeInternalScripts = false) => {
|
||||
});
|
||||
|
||||
program
|
||||
.command('gui:build')
|
||||
.description('Builds @grafana/ui package to packages/grafana-ui/dist')
|
||||
.command('package:build')
|
||||
.option('-s, --scope <packages>', 'packages=[data|runtime|ui|toolkit]')
|
||||
.description('Builds @grafana/* package to packages/grafana-*/dist')
|
||||
.action(async cmd => {
|
||||
// @ts-ignore
|
||||
await execTask(buildTask)();
|
||||
});
|
||||
|
||||
program
|
||||
.command('gui:release')
|
||||
.description('Prepares @grafana/ui release (and publishes to npm on demand)')
|
||||
.option('-p, --publish', 'Publish @grafana/ui to npm registry')
|
||||
.option('-u, --usePackageJsonVersion', 'Use version specified in package.json')
|
||||
.option('--createVersionCommit', 'Create and push version commit')
|
||||
.action(async cmd => {
|
||||
await execTask(releaseTask)({
|
||||
publishToNpm: !!cmd.publish,
|
||||
usePackageJsonVersion: !!cmd.usePackageJsonVersion,
|
||||
createVersionCommit: !!cmd.createVersionCommit,
|
||||
await execTask(buildPackageTask)({
|
||||
scope: cmd.scope,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from 'axios';
|
||||
// @ts-ignore
|
||||
import * as _ from 'lodash';
|
||||
import { Task, TaskRunner } from './task';
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import execa = require('execa');
|
||||
import { execTask } from '../utils/execTask';
|
||||
import { changeCwdToGrafanaUiDist, changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd';
|
||||
import { ReleaseType, inc } from 'semver';
|
||||
import { prompt } from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { useSpinner } from '../utils/useSpinner';
|
||||
import { savePackage, buildTask, clean } from './grafanaui.build';
|
||||
import { TaskRunner, Task } from './task';
|
||||
|
||||
type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major';
|
||||
|
||||
interface ReleaseTaskOptions {
|
||||
publishToNpm: boolean;
|
||||
usePackageJsonVersion: boolean;
|
||||
createVersionCommit: boolean;
|
||||
}
|
||||
|
||||
const promptBumpType = async () => {
|
||||
return prompt<{ type: VersionBumpType }>([
|
||||
{
|
||||
type: 'list',
|
||||
message: 'Select version bump',
|
||||
name: 'type',
|
||||
choices: ['prerelease', 'patch', 'minor', 'major'],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const promptPrereleaseId = async (message = 'Is this a prerelease?', allowNo = true) => {
|
||||
return prompt<{ id: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
message: message,
|
||||
name: 'id',
|
||||
choices: allowNo ? ['no', 'alpha', 'beta'] : ['alpha', 'beta'],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const promptConfirm = async (message?: string) => {
|
||||
return prompt<{ confirmed: boolean }>([
|
||||
{
|
||||
type: 'confirm',
|
||||
message: message || 'Is that correct?',
|
||||
name: 'confirmed',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Since Grafana core depends on @grafana/ui highly, we run full check before release
|
||||
const runChecksAndTests = async () =>
|
||||
// @ts-ignore
|
||||
useSpinner<void>(`Running checks and tests`, async () => {
|
||||
try {
|
||||
await execa('npm', ['run', 'test']);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
|
||||
const bumpVersion = (version: string) =>
|
||||
// @ts-ignore
|
||||
useSpinner<void>(`Saving version ${version} to package.json`, async () => {
|
||||
changeCwdToGrafanaUi();
|
||||
await execa('npm', ['version', version]);
|
||||
changeCwdToGrafanaUiDist();
|
||||
const pkg = require(`${process.cwd()}/package.json`);
|
||||
pkg.version = version;
|
||||
await savePackage({ path: `${process.cwd()}/package.json`, pkg });
|
||||
})();
|
||||
|
||||
const publishPackage = (name: string, version: string) =>
|
||||
// @ts-ignore
|
||||
useSpinner<void>(`Publishing ${name} @ ${version} to npm registry...`, async () => {
|
||||
changeCwdToGrafanaUiDist();
|
||||
await execa('npm', ['publish', '--access', 'public']);
|
||||
})();
|
||||
|
||||
const ensureMasterBranch = async () => {
|
||||
const currentBranch = await execa.stdout('git', ['symbolic-ref', '--short', 'HEAD']);
|
||||
const status = await execa.stdout('git', ['status', '--porcelain']);
|
||||
|
||||
if (currentBranch !== 'master' && status !== '') {
|
||||
console.error(chalk.red.bold('You need to be on clean master branch to release @grafana/ui'));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareVersionCommitAndPush = async (version: string) =>
|
||||
// @ts-ignore
|
||||
useSpinner<void>('Commiting and pushing @grafana/ui version update', async () => {
|
||||
await execa.stdout('git', ['commit', '-a', '-m', `Upgrade @grafana/ui version to v${version}`]);
|
||||
await execa.stdout('git', ['push']);
|
||||
})();
|
||||
|
||||
const releaseTaskRunner: TaskRunner<ReleaseTaskOptions> = async ({
|
||||
publishToNpm,
|
||||
usePackageJsonVersion,
|
||||
createVersionCommit,
|
||||
}) => {
|
||||
changeCwdToGrafanaUi();
|
||||
// @ts-ignore
|
||||
await clean(); // Clean previous build if exists
|
||||
restoreCwd();
|
||||
|
||||
if (publishToNpm) {
|
||||
// TODO: Ensure release branch
|
||||
// When need to update this when we star keeping @grafana/ui releases in sync with core
|
||||
await ensureMasterBranch();
|
||||
}
|
||||
|
||||
await runChecksAndTests();
|
||||
|
||||
await execTask(buildTask)({} as any);
|
||||
|
||||
let releaseConfirmed = false;
|
||||
let nextVersion;
|
||||
changeCwdToGrafanaUiDist();
|
||||
|
||||
const pkg = require(`${process.cwd()}/package.json`);
|
||||
|
||||
console.log(`Current version: ${pkg.version}`);
|
||||
|
||||
do {
|
||||
if (!usePackageJsonVersion) {
|
||||
const { type } = await promptBumpType();
|
||||
console.log(type);
|
||||
if (type === 'prerelease') {
|
||||
const { id } = await promptPrereleaseId('What kind of prerelease?', false);
|
||||
nextVersion = inc(pkg.version, type, id as any);
|
||||
} else {
|
||||
const { id } = await promptPrereleaseId();
|
||||
if (id !== 'no') {
|
||||
nextVersion = inc(pkg.version, `pre${type}` as ReleaseType, id as any);
|
||||
} else {
|
||||
nextVersion = inc(pkg.version, type as ReleaseType);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nextVersion = pkg.version;
|
||||
}
|
||||
|
||||
console.log(chalk.yellowBright.bold(`You are going to release a new version of ${pkg.name}`));
|
||||
|
||||
if (usePackageJsonVersion) {
|
||||
console.log(chalk.green(`Version based on package.json: `), chalk.bold.yellowBright(`${nextVersion}`));
|
||||
} else {
|
||||
console.log(chalk.green(`Version bump: ${pkg.version} ->`), chalk.bold.yellowBright(`${nextVersion}`));
|
||||
}
|
||||
|
||||
const { confirmed } = await promptConfirm();
|
||||
|
||||
releaseConfirmed = confirmed;
|
||||
} while (!releaseConfirmed);
|
||||
|
||||
if (!usePackageJsonVersion) {
|
||||
await bumpVersion(nextVersion);
|
||||
}
|
||||
|
||||
if (createVersionCommit) {
|
||||
await prepareVersionCommitAndPush(nextVersion);
|
||||
}
|
||||
|
||||
if (publishToNpm) {
|
||||
console.log(chalk.yellowBright.bold(`\nReview dist package.json before proceeding!\n`));
|
||||
const { confirmed } = await promptConfirm('Are you ready to publish to npm?');
|
||||
|
||||
if (!confirmed) {
|
||||
process.exit();
|
||||
}
|
||||
|
||||
await publishPackage(pkg.name, nextVersion);
|
||||
console.log(chalk.green(`\nVersion ${nextVersion} of ${pkg.name} succesfully released!`));
|
||||
console.log(chalk.yellow(`\nUpdated @grafana/ui/package.json with version bump created.`));
|
||||
|
||||
process.exit();
|
||||
} else {
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\nVersion ${nextVersion} of ${pkg.name} succesfully prepared for release. See packages/grafana-ui/dist`
|
||||
)
|
||||
);
|
||||
console.log(chalk.green(`\nTo publish to npm registry run`), chalk.bold.blue(`npm run gui:publish`));
|
||||
}
|
||||
};
|
||||
|
||||
export const releaseTask = new Task<ReleaseTaskOptions>('@grafana/ui release', releaseTaskRunner);
|
||||
@@ -1,7 +1,8 @@
|
||||
import execa = require('execa');
|
||||
// @ts-ignore
|
||||
import * as fs from 'fs';
|
||||
import { changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd';
|
||||
// @ts-ignore
|
||||
import * as path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { useSpinner } from '../utils/useSpinner';
|
||||
import { Task, TaskRunner } from './task';
|
||||
@@ -15,7 +16,7 @@ export const clean = useSpinner<void>('Cleaning', async () => await execa('npm',
|
||||
const compile = useSpinner<void>('Compiling sources', () => execa('tsc', ['-p', './tsconfig.build.json']));
|
||||
|
||||
// @ts-ignore
|
||||
const rollup = useSpinner<void>('Bundling', () => execa('npm', ['run', 'build']));
|
||||
const rollup = useSpinner<void>('Bundling', () => execa('npm', ['run', 'bundle']));
|
||||
|
||||
interface SavePackageOptions {
|
||||
path: string;
|
||||
@@ -68,19 +69,34 @@ const moveFiles = () => {
|
||||
})();
|
||||
};
|
||||
|
||||
const buildTaskRunner: TaskRunner<void> = async () => {
|
||||
cwd = changeCwdToGrafanaUi();
|
||||
distDir = `${cwd}/dist`;
|
||||
const pkg = require(`${cwd}/package.json`);
|
||||
console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`));
|
||||
interface PackageBuildOptions {
|
||||
scope: string;
|
||||
}
|
||||
|
||||
await clean();
|
||||
await compile();
|
||||
await rollup();
|
||||
await preparePackage(pkg);
|
||||
await moveFiles();
|
||||
const buildTaskRunner: TaskRunner<PackageBuildOptions> = async ({ scope }) => {
|
||||
if (!scope) {
|
||||
throw new Error('Provide packages with -s, --scope <packages>');
|
||||
}
|
||||
|
||||
restoreCwd();
|
||||
const scopes = scope.split(',').map(s => {
|
||||
return async () => {
|
||||
cwd = path.resolve(__dirname, `../../../../grafana-${s}`);
|
||||
// Lerna executes this in package's dir context, but for testing purposes I want to be able to run from root:
|
||||
// grafana-toolkit package:build --scope=<package>
|
||||
process.chdir(cwd);
|
||||
distDir = `${cwd}/dist`;
|
||||
const pkg = require(`${cwd}/package.json`);
|
||||
console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`));
|
||||
|
||||
await clean();
|
||||
await compile();
|
||||
await rollup();
|
||||
await preparePackage(pkg);
|
||||
await moveFiles();
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(scopes.map(s => s()));
|
||||
};
|
||||
|
||||
export const buildTask = new Task<void>('@grafana/ui build', buildTaskRunner);
|
||||
export const buildPackageTask = new Task<PackageBuildOptions>('@grafana/ui build', buildTaskRunner);
|
||||
@@ -4,7 +4,6 @@ import execa = require('execa');
|
||||
import path = require('path');
|
||||
import fs = require('fs');
|
||||
import glob = require('glob');
|
||||
import util = require('util');
|
||||
import { Linter, Configuration, RuleFailure } from 'tslint';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
@@ -17,7 +16,6 @@ interface PluginBuildOptions {
|
||||
|
||||
export const bundlePlugin = useSpinner<PluginBundleOptions>('Compiling...', async options => await bundleFn(options));
|
||||
|
||||
const readFileAsync = util.promisify(fs.readFile);
|
||||
// @ts-ignore
|
||||
export const clean = useSpinner<void>('Cleaning', async () => await execa('rimraf', [`${process.cwd()}/dist`]));
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Task, TaskRunner } from './task';
|
||||
import { pluginBuildRunner } from './plugin.build';
|
||||
import { useSpinner } from '../utils/useSpinner';
|
||||
import { restoreCwd } from '../utils/cwd';
|
||||
import { getPluginJson } from '../../config/utils/pluginValidation';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import path = require('path');
|
||||
import fs = require('fs');
|
||||
import webpack = require('webpack');
|
||||
import { getWebpackConfig } from '../../../config/webpack.plugin.config';
|
||||
import formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path = require('path');
|
||||
import * as jestCLI from 'jest-cli';
|
||||
import { useSpinner } from '../../utils/useSpinner';
|
||||
import { jestConfig } from '../../../config/jest.plugin.config';
|
||||
|
||||
@@ -46,7 +46,6 @@ export async function getTeam(team: any): Promise<any> {
|
||||
}
|
||||
|
||||
export async function addToTeam(team: any, user: any): Promise<any> {
|
||||
const members = await client.get(`/teams/${team.id}/members`);
|
||||
console.log(`Adding user ${user.name} to team ${team.name}`);
|
||||
await client.post(`/teams/${team.id}/members`, { userId: user.id });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import execa = require('execa');
|
||||
import * as fs from 'fs';
|
||||
import { changeCwdToGrafanaUi, restoreCwd, changeCwdToGrafanaToolkit } from '../utils/cwd';
|
||||
import chalk from 'chalk';
|
||||
import { useSpinner } from '../utils/useSpinner';
|
||||
import { Task, TaskRunner } from './task';
|
||||
@@ -102,7 +101,7 @@ const copySassFiles = () => {
|
||||
};
|
||||
|
||||
const toolkitBuildTaskRunner: TaskRunner<void> = async () => {
|
||||
cwd = changeCwdToGrafanaToolkit();
|
||||
cwd = path.resolve(__dirname, '../../../');
|
||||
distDir = `${cwd}/dist`;
|
||||
const pkg = require(`${cwd}/package.json`);
|
||||
console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`));
|
||||
@@ -114,7 +113,6 @@ const toolkitBuildTaskRunner: TaskRunner<void> = async () => {
|
||||
fs.mkdirSync('./dist/sass');
|
||||
await moveFiles();
|
||||
await copySassFiles();
|
||||
restoreCwd();
|
||||
};
|
||||
|
||||
export const toolkitBuildTask = new Task<void>('@grafana/toolkit build', toolkitBuildTaskRunner);
|
||||
|
||||
@@ -17,3 +17,15 @@ export const changeCwdToGrafanaUiDist = () => {
|
||||
export const restoreCwd = () => {
|
||||
process.chdir(cwd);
|
||||
};
|
||||
|
||||
type PackageId = 'ui' | 'data' | 'runtime' | 'toolkit';
|
||||
|
||||
export const changeCwdToPackage = (scope: PackageId) => {
|
||||
try {
|
||||
process.chdir(`${cwd}/packages/grafana-${scope}`);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return process.cwd();
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const ngAnnotatePlugin = require('ng-annotate-webpack-plugin');
|
||||
|
||||
import * as webpack from 'webpack';
|
||||
import { hasThemeStylesheets, getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders';
|
||||
import { getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders';
|
||||
|
||||
interface WebpackConfigurationOptions {
|
||||
watch?: boolean;
|
||||
@@ -51,6 +51,7 @@ const getManualChunk = (id: string) => {
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntries = () => {
|
||||
@@ -83,8 +84,8 @@ const getCommonPlugins = (options: WebpackConfigurationOptions) => {
|
||||
{ from: '../LICENSE', to: '.' },
|
||||
{ from: 'img/*', to: '.' },
|
||||
{ from: '**/*.json', to: '.' },
|
||||
// { from: '**/*.svg', to: '.' },
|
||||
// { from: '**/*.png', to: '.' },
|
||||
{ from: '**/*.svg', to: '.' },
|
||||
{ from: '**/*.png', to: '.' },
|
||||
{ from: '**/*.html', to: '.' },
|
||||
],
|
||||
{ logLevel: options.watch ? 'silent' : 'warn' }
|
||||
@@ -114,7 +115,6 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => {
|
||||
const optimization: { [key: string]: any } = {};
|
||||
|
||||
if (options.production) {
|
||||
plugins.push(new ngAnnotatePlugin());
|
||||
optimization.minimizer = [new TerserPlugin(), new OptimizeCssAssetsPlugin()];
|
||||
}
|
||||
|
||||
@@ -177,8 +177,12 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => {
|
||||
loaders: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: { presets: ['@babel/preset-env'] },
|
||||
options: {
|
||||
presets: ['@babel/preset-env'],
|
||||
plugins: ['angularjs-annotate'],
|
||||
},
|
||||
},
|
||||
|
||||
'ts-loader',
|
||||
],
|
||||
exclude: /(node_modules)/,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getStylesheetEntries, hasThemeStylesheets } from './loaders';
|
||||
describe('Loaders', () => {
|
||||
describe('stylesheet helpers', () => {
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
afterAll(() => {
|
||||
logSpy.mockRestore();
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { getPluginJson } from '../utils/pluginValidation';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
@@ -122,8 +119,8 @@ export const getFileLoaders = () => {
|
||||
? {
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
outputPath: 'static',
|
||||
name: '[name].[hash:8].[ext]',
|
||||
outputPath: '/',
|
||||
name: '[path][name].[ext]',
|
||||
},
|
||||
}
|
||||
: // When using single css import images are inlined as base64 URIs in the result bundle
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"rootDirs": ["."],
|
||||
"outDir": "dist/src",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"declaration": false,
|
||||
"typeRoots": ["./node_modules/@types"],
|
||||
"skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["es2015", "es2017.string"]
|
||||
}
|
||||
|
||||
@@ -15,37 +15,3 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra
|
||||
## Development
|
||||
|
||||
For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead.
|
||||
|
||||
## Building @grafana/ui
|
||||
|
||||
To build @grafana/ui run `npm run gui:build` script _from Grafana repository root_. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package.
|
||||
|
||||
## Releasing new version
|
||||
|
||||
To release new version run `npm run gui:release` script _from Grafana repository root_. This has to be done on the master branch. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. When the new package is published, create a PR with the bumped version in package.json.
|
||||
|
||||
### Automatic version bump
|
||||
|
||||
When running `npm run gui:release` package.json file will be automatically updated. Also, package.json file will be commited and pushed to upstream branch.
|
||||
|
||||
### Manual version bump
|
||||
|
||||
Manually update the version in `package.json` and then run `npm run gui:release --usePackageJsonVersion` _from Grafana repository root_.
|
||||
|
||||
### Preparing release package without publishing to NPM registry
|
||||
|
||||
For testing purposes there is `npm run gui:releasePrepare` task that prepares distribution package without publishing it to the NPM registry.
|
||||
|
||||
### V1 release process overview
|
||||
|
||||
1. Package is compiled with TSC. Typings are created in `/dist` directory, and the compiled js lands in `/compiled` dir
|
||||
2. Rollup creates a CommonJS package based on compiled sources, and outputs it to `/dist` directory
|
||||
3. Readme, changelog and index.js files are moved to `/dist` directory
|
||||
4. Package version is bumped in both `@grafana/ui` package dir and in dist directory.
|
||||
5. Version commit is created and pushed to master branch
|
||||
6. Package is published to npm
|
||||
|
||||
## Versioning
|
||||
|
||||
To limit the confusion related to @grafana/ui and Grafana versioning we decided to keep the major version in sync between those two.
|
||||
This means, that first version of @grafana/ui is taged with 6.0.0-alpha.0 to keep version in sync with Grafana 6.0 release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana/ui",
|
||||
"version": "6.3.0-alpha.1",
|
||||
"version": "6.3.0-alpha.36",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
@@ -14,7 +14,9 @@
|
||||
"storybook": "start-storybook -p 9001 -c .storybook",
|
||||
"storybook:build": "build-storybook -o ./dist/storybook -c .storybook",
|
||||
"clean": "rimraf ./dist ./compiled",
|
||||
"build": "rollup -c rollup.config.ts"
|
||||
"bundle": "rollup -c rollup.config.ts",
|
||||
"build": "grafana-toolkit package:build --scope=ui",
|
||||
"postpublish": "npm run clean"
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -49,9 +49,9 @@ function addBarGaugeStory(name: string, overrides: Partial<Props>) {
|
||||
orientation: VizOrientation.Vertical,
|
||||
displayMode: 'basic',
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: 'green' },
|
||||
{ index: 1, value: threshold1Value, color: threshold1Color },
|
||||
{ index: 1, value: threshold2Value, color: threshold2Color },
|
||||
{ value: -Infinity, color: 'green' },
|
||||
{ value: threshold1Value, color: threshold1Color },
|
||||
{ value: threshold2Value, color: threshold2Color },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -25,11 +25,7 @@ function getProps(propOverrides?: Partial<Props>): Props {
|
||||
maxValue: 100,
|
||||
minValue: 0,
|
||||
displayMode: 'basic',
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: 'green' },
|
||||
{ index: 1, value: 70, color: 'orange' },
|
||||
{ index: 2, value: 90, color: 'red' },
|
||||
],
|
||||
thresholds: [{ value: -Infinity, color: 'green' }, { value: 70, color: 'orange' }, { value: 90, color: 'red' }],
|
||||
height: 300,
|
||||
width: 300,
|
||||
value: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getColorFromHexRgbOrName } from '../../utils';
|
||||
|
||||
// Types
|
||||
import { DisplayValue, Themeable, VizOrientation } from '../../types';
|
||||
import { Threshold, TimeSeriesValue, getThresholdForValue } from '@grafana/data';
|
||||
import { Threshold, TimeSeriesValue, getActiveThreshold } from '@grafana/data';
|
||||
|
||||
const MIN_VALUE_HEIGHT = 18;
|
||||
const MAX_VALUE_HEIGHT = 50;
|
||||
@@ -87,8 +87,14 @@ export class BarGauge extends PureComponent<Props> {
|
||||
|
||||
getCellColor(positionValue: TimeSeriesValue): CellColors {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
const activeThreshold = getThresholdForValue(thresholds, positionValue);
|
||||
if (positionValue === null) {
|
||||
return {
|
||||
background: 'gray',
|
||||
border: 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
const activeThreshold = getActiveThreshold(positionValue, thresholds);
|
||||
if (activeThreshold !== null) {
|
||||
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
@@ -474,7 +480,7 @@ export function getBarGradient(props: Props, maxSize: number): string {
|
||||
export function getValueColor(props: Props): string {
|
||||
const { thresholds, theme, value } = props;
|
||||
|
||||
const activeThreshold = getThresholdForValue(thresholds, value.numeric);
|
||||
const activeThreshold = getActiveThreshold(value.numeric, thresholds);
|
||||
|
||||
if (activeThreshold !== null) {
|
||||
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
@@ -14,7 +14,7 @@ const setup = (propOverrides?: object) => {
|
||||
minValue: 0,
|
||||
showThresholdMarkers: true,
|
||||
showThresholdLabels: false,
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
|
||||
thresholds: [{ value: -Infinity, color: '#7EB26D' }],
|
||||
height: 300,
|
||||
width: 300,
|
||||
value: {
|
||||
@@ -48,9 +48,9 @@ describe('Get thresholds formatted', () => {
|
||||
it('should get the correct formatted values when thresholds are added', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 50, color: '#EAB839' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ export class Gauge extends PureComponent<Props> {
|
||||
const lastThreshold = thresholds[thresholds.length - 1];
|
||||
|
||||
return [
|
||||
...thresholds.map(threshold => {
|
||||
if (threshold.index === 0) {
|
||||
...thresholds.map((threshold, index) => {
|
||||
if (index === 0) {
|
||||
return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme.type) };
|
||||
}
|
||||
|
||||
const previousThreshold = thresholds[threshold.index - 1];
|
||||
const previousThreshold = thresholds[index - 1];
|
||||
return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme.type) };
|
||||
}),
|
||||
{ value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme.type) },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { SelectOptionItem } from '../Select/Select';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
import { ButtonSelect } from '../Select/ButtonSelect';
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RefreshPicker extends PureComponent<Props> {
|
||||
super(props);
|
||||
}
|
||||
|
||||
intervalsToOptions = (intervals: string[] | undefined): Array<SelectOptionItem<string>> => {
|
||||
intervalsToOptions = (intervals: string[] | undefined): Array<SelectableValue<string>> => {
|
||||
const intervalsOrDefault = intervals || defaultIntervals;
|
||||
const options = intervalsOrDefault
|
||||
.filter(str => str !== '')
|
||||
@@ -37,7 +37,7 @@ export class RefreshPicker extends PureComponent<Props> {
|
||||
return options;
|
||||
};
|
||||
|
||||
onChangeSelect = (item: SelectOptionItem<string>) => {
|
||||
onChangeSelect = (item: SelectableValue<string>) => {
|
||||
const { onIntervalChanged } = this.props;
|
||||
if (onIntervalChanged) {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -4,7 +4,7 @@ import { action } from '@storybook/addon-actions';
|
||||
import { withKnobs, object, text } from '@storybook/addon-knobs';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { SelectOptionItem } from './Select';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { ButtonSelect } from './ButtonSelect';
|
||||
|
||||
const ButtonSelectStories = storiesOf('UI/Select/ButtonSelect', module);
|
||||
@@ -12,9 +12,9 @@ const ButtonSelectStories = storiesOf('UI/Select/ButtonSelect', module);
|
||||
ButtonSelectStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
||||
|
||||
ButtonSelectStories.add('default', () => {
|
||||
const intialState: SelectOptionItem<string> = { label: 'A label', value: 'A value' };
|
||||
const value = object<SelectOptionItem<string>>('Selected Value:', intialState);
|
||||
const options = object<Array<SelectOptionItem<string>>>('Options:', [
|
||||
const intialState: SelectableValue<string> = { label: 'A label', value: 'A value' };
|
||||
const value = object<SelectableValue<string>>('Selected Value:', intialState);
|
||||
const options = object<Array<SelectableValue<string>>>('Options:', [
|
||||
intialState,
|
||||
{ label: 'Another label', value: 'Another value' },
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { PureComponent, ReactElement } from 'react';
|
||||
import Select, { SelectOptionItem } from './Select';
|
||||
import Select from './Select';
|
||||
import { PopperContent } from '../Tooltip/PopperController';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
interface ButtonComponentProps {
|
||||
label: ReactElement | string | undefined;
|
||||
@@ -30,13 +31,13 @@ const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => {
|
||||
|
||||
export interface Props<T> {
|
||||
className: string | undefined;
|
||||
options: Array<SelectOptionItem<T>>;
|
||||
value?: SelectOptionItem<T>;
|
||||
options: Array<SelectableValue<T>>;
|
||||
value?: SelectableValue<T>;
|
||||
label?: ReactElement | string;
|
||||
iconClass?: string;
|
||||
components?: any;
|
||||
maxMenuHeight?: number;
|
||||
onChange: (item: SelectOptionItem<T>) => void;
|
||||
onChange: (item: SelectableValue<T>) => void;
|
||||
tooltipContent?: PopperContent<any>;
|
||||
isMenuOpen?: boolean;
|
||||
onOpenMenu?: () => void;
|
||||
@@ -45,7 +46,7 @@ export interface Props<T> {
|
||||
}
|
||||
|
||||
export class ButtonSelect<T> extends PureComponent<Props<T>> {
|
||||
onChange = (item: SelectOptionItem<T>) => {
|
||||
onChange = (item: SelectableValue<T>) => {
|
||||
const { onChange } = this.props;
|
||||
onChange(item);
|
||||
};
|
||||
|
||||
@@ -19,23 +19,16 @@ import resetSelectStyles from './resetSelectStyles';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { PopperContent } from '../Tooltip/PopperController';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
export interface SelectOptionItem<T> {
|
||||
label?: string;
|
||||
value?: T;
|
||||
imgUrl?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
export interface CommonProps<T> {
|
||||
defaultValue?: any;
|
||||
getOptionLabel?: (item: SelectOptionItem<T>) => string;
|
||||
getOptionValue?: (item: SelectOptionItem<T>) => string;
|
||||
onChange: (item: SelectOptionItem<T>) => {} | void;
|
||||
getOptionLabel?: (item: SelectableValue<T>) => string;
|
||||
getOptionValue?: (item: SelectableValue<T>) => string;
|
||||
onChange: (item: SelectableValue<T>) => {} | void;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
value?: SelectOptionItem<T>;
|
||||
value?: SelectableValue<T>;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isSearchable?: boolean;
|
||||
@@ -57,12 +50,12 @@ export interface CommonProps<T> {
|
||||
}
|
||||
|
||||
export interface SelectProps<T> extends CommonProps<T> {
|
||||
options: Array<SelectOptionItem<T>>;
|
||||
options: Array<SelectableValue<T>>;
|
||||
}
|
||||
|
||||
interface AsyncProps<T> extends CommonProps<T> {
|
||||
defaultOptions: boolean;
|
||||
loadOptions: (query: string) => Promise<Array<SelectOptionItem<T>>>;
|
||||
loadOptions: (query: string) => Promise<Array<SelectableValue<T>>>;
|
||||
loadingMessage?: () => string;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ import { interval, Subscription, Subject, of, NEVER } from 'rxjs';
|
||||
import { tap, switchMap } from 'rxjs/operators';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { stringToMs } from '@grafana/data';
|
||||
import { stringToMs, SelectableValue } from '@grafana/data';
|
||||
import { isLive } from '../RefreshPicker/RefreshPicker';
|
||||
import { SelectOptionItem } from '../Select/Select';
|
||||
|
||||
export function getIntervalFromString(strInterval: string): SelectOptionItem<number> {
|
||||
export function getIntervalFromString(strInterval: string): SelectableValue<number> {
|
||||
return {
|
||||
label: strInterval,
|
||||
value: stringToMs(strInterval),
|
||||
|
||||
@@ -8,10 +8,10 @@ import { StatsPicker } from '../StatsPicker/StatsPicker';
|
||||
|
||||
// Types
|
||||
import { FieldDisplayOptions, DEFAULT_FIELD_DISPLAY_VALUES_LIMIT } from '../../utils/fieldDisplay';
|
||||
import Select, { SelectOptionItem } from '../Select/Select';
|
||||
import { Field, ReducerID, toNumberString, toIntegerOrUndefined } from '@grafana/data';
|
||||
import Select from '../Select/Select';
|
||||
import { Field, ReducerID, toNumberString, toIntegerOrUndefined, SelectableValue } from '@grafana/data';
|
||||
|
||||
const showOptions: Array<SelectOptionItem<boolean>> = [
|
||||
const showOptions: Array<SelectableValue<boolean>> = [
|
||||
{
|
||||
value: true,
|
||||
label: 'All Values',
|
||||
@@ -31,7 +31,7 @@ export interface Props {
|
||||
}
|
||||
|
||||
export class FieldDisplayEditor extends PureComponent<Props> {
|
||||
onShowValuesChange = (item: SelectOptionItem<boolean>) => {
|
||||
onShowValuesChange = (item: SelectableValue<boolean>) => {
|
||||
const val = item.value === true;
|
||||
this.props.onChange({ ...this.props.value, values: val });
|
||||
};
|
||||
|
||||
@@ -7,8 +7,7 @@ import { FormLabel } from '../FormLabel/FormLabel';
|
||||
import { UnitPicker } from '../UnitPicker/UnitPicker';
|
||||
|
||||
// Types
|
||||
import { toIntegerOrUndefined, Field } from '@grafana/data';
|
||||
import { SelectOptionItem } from '../Select/Select';
|
||||
import { toIntegerOrUndefined, Field, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { VAR_SERIES_NAME, VAR_FIELD_NAME, VAR_CALC, VAR_CELL_PREFIX } from '../../utils/fieldDisplay';
|
||||
|
||||
@@ -54,7 +53,7 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
|
||||
[value.max, onChange]
|
||||
);
|
||||
|
||||
const onUnitChange = (unit: SelectOptionItem<string>) => {
|
||||
const onUnitChange = (unit: SelectableValue<string>) => {
|
||||
onChange({ ...value, unit: unit.value });
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import omit from 'lodash/omit';
|
||||
|
||||
import { VizOrientation, PanelModel } from '../../types/panel';
|
||||
import { FieldDisplayOptions } from '../../utils/fieldDisplay';
|
||||
import { Field, getFieldReducers } from '@grafana/data';
|
||||
import { Field, fieldReducers, Threshold, sortThresholds } from '@grafana/data';
|
||||
|
||||
export interface SingleStatBaseOptions {
|
||||
fieldOptions: FieldDisplayOptions;
|
||||
@@ -39,17 +39,18 @@ export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseO
|
||||
const { valueOptions } = old;
|
||||
|
||||
const fieldOptions = (old.fieldOptions = {} as FieldDisplayOptions);
|
||||
fieldOptions.mappings = old.valueMappings;
|
||||
fieldOptions.thresholds = old.thresholds;
|
||||
|
||||
const field = (fieldOptions.defaults = {} as Field);
|
||||
if (valueOptions) {
|
||||
field.unit = valueOptions.unit;
|
||||
field.decimals = valueOptions.decimals;
|
||||
field.mappings = old.valueMappings;
|
||||
field.thresholds = migrateOldThresholds(old.thresholds);
|
||||
field.unit = valueOptions.unit;
|
||||
field.decimals = valueOptions.decimals;
|
||||
|
||||
// Make sure the stats have a valid name
|
||||
if (valueOptions.stat) {
|
||||
fieldOptions.calcs = getFieldReducers([valueOptions.stat]).map(s => s.id);
|
||||
// Make sure the stats have a valid name
|
||||
if (valueOptions.stat) {
|
||||
const reducer = fieldReducers.get(valueOptions.stat);
|
||||
if (reducer) {
|
||||
fieldOptions.calcs = [reducer.id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +59,33 @@ export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseO
|
||||
|
||||
// remove old props
|
||||
return omit(old, 'valueMappings', 'thresholds', 'valueOptions', 'minValue', 'maxValue');
|
||||
} else if (old.fieldOptions) {
|
||||
// Move mappins & thresholds to field defautls (6.4+)
|
||||
const { mappings, thresholds, ...fieldOptions } = old.fieldOptions;
|
||||
fieldOptions.defaults = {
|
||||
mappings,
|
||||
thresholds: migrateOldThresholds(thresholds),
|
||||
...fieldOptions.defaults,
|
||||
};
|
||||
old.fieldOptions = fieldOptions;
|
||||
return old;
|
||||
}
|
||||
|
||||
return panel.options;
|
||||
};
|
||||
|
||||
export function migrateOldThresholds(thresholds?: any[]): Threshold[] | undefined {
|
||||
if (!thresholds || !thresholds.length) {
|
||||
return undefined;
|
||||
}
|
||||
const copy = thresholds.map(t => {
|
||||
return {
|
||||
// Drops 'index'
|
||||
value: t.value === null ? -Infinity : t.value,
|
||||
color: t.color,
|
||||
};
|
||||
});
|
||||
sortThresholds(copy);
|
||||
copy[0].value = -Infinity;
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import difference from 'lodash/difference';
|
||||
|
||||
import { Select } from '../index';
|
||||
|
||||
import { getFieldReducers } from '@grafana/data';
|
||||
import { SelectOptionItem } from '../Select/Select';
|
||||
import { fieldReducers, SelectableValue } from '@grafana/data';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
@@ -34,7 +33,7 @@ export class StatsPicker extends PureComponent<Props> {
|
||||
checkInput = () => {
|
||||
const { stats, allowMultiple, defaultStat, onChange } = this.props;
|
||||
|
||||
const current = getFieldReducers(stats);
|
||||
const current = fieldReducers.list(stats);
|
||||
if (current.length !== stats.length) {
|
||||
const found = current.map(v => v.id);
|
||||
const notFound = difference(stats, found);
|
||||
@@ -54,7 +53,7 @@ export class StatsPicker extends PureComponent<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onSelectionChange = (item: SelectOptionItem<string>) => {
|
||||
onSelectionChange = (item: SelectableValue<string>) => {
|
||||
const { onChange } = this.props;
|
||||
if (isArray(item)) {
|
||||
onChange(item.map(v => v.value));
|
||||
@@ -65,24 +64,16 @@ export class StatsPicker extends PureComponent<Props> {
|
||||
|
||||
render() {
|
||||
const { width, stats, allowMultiple, defaultStat, placeholder } = this.props;
|
||||
const options = getFieldReducers().map(s => {
|
||||
return {
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
description: s.description,
|
||||
};
|
||||
});
|
||||
|
||||
const value: Array<SelectOptionItem<string>> = options.filter(option => stats.find(stat => option.value === stat));
|
||||
|
||||
const select = fieldReducers.selectOptions(stats);
|
||||
return (
|
||||
<Select
|
||||
width={width}
|
||||
value={value}
|
||||
value={select.current}
|
||||
isClearable={!defaultStat}
|
||||
isMulti={allowMultiple}
|
||||
isSearchable={true}
|
||||
options={options}
|
||||
options={select.options}
|
||||
placeholder={placeholder}
|
||||
onChange={this.onSelectionChange}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { ThresholdsEditor, Props } from './ThresholdsEditor';
|
||||
import { ThresholdsEditor, Props, threshodsWithoutKey } from './ThresholdsEditor';
|
||||
import { colors } from '../../utils';
|
||||
|
||||
const setup = (propOverrides?: Partial<Props>) => {
|
||||
@@ -20,6 +20,10 @@ const setup = (propOverrides?: Partial<Props>) => {
|
||||
};
|
||||
};
|
||||
|
||||
function getCurrentThresholds(editor: ThresholdsEditor) {
|
||||
return threshodsWithoutKey(editor.state.thresholds);
|
||||
}
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render with base threshold', () => {
|
||||
const { wrapper } = setup();
|
||||
@@ -32,60 +36,55 @@ describe('Initialization', () => {
|
||||
it('should add a base threshold if missing', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
expect(getCurrentThresholds(instance)).toEqual([{ value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add threshold', () => {
|
||||
it('should not add threshold at index 0', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
instance.onAddThreshold(0);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
|
||||
it('should add threshold', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
instance.onAddThreshold(1);
|
||||
instance.onAddThresholdAfter(instance.state.thresholds[0]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: colors[0] }, // 0
|
||||
{ value: 50, color: colors[2] }, // 1
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold above a first', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
|
||||
thresholds: [
|
||||
{ value: -Infinity, color: colors[0] }, // 0
|
||||
{ value: 50, color: colors[2] }, // 1
|
||||
],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
instance.onAddThresholdAfter(instance.state.thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: colors[0] }, // 0
|
||||
{ value: 50, color: colors[2] }, // 1
|
||||
{ value: 75, color: colors[3] }, // 2
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold between first and second index', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
{ value: -Infinity, color: colors[0] },
|
||||
{ value: 50, color: colors[2] },
|
||||
{ value: 75, color: colors[3] },
|
||||
],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
instance.onAddThresholdAfter(instance.state.thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 62.5, color: colors[4] },
|
||||
{ index: 3, value: 75, color: colors[3] },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: colors[0] },
|
||||
{ value: 50, color: colors[2] },
|
||||
{ value: 62.5, color: colors[4] },
|
||||
{ value: 75, color: colors[3] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -93,30 +92,30 @@ describe('Add threshold', () => {
|
||||
describe('Remove threshold', () => {
|
||||
it('should not remove threshold at index 0', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 50, color: '#EAB839' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const { instance } = setup({ thresholds });
|
||||
|
||||
instance.onRemoveThreshold(thresholds[0]);
|
||||
instance.onRemoveThreshold(instance.state.thresholds[0]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual(thresholds);
|
||||
expect(getCurrentThresholds(instance)).toEqual(thresholds);
|
||||
});
|
||||
|
||||
it('should remove threshold', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 50, color: '#EAB839' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const { instance } = setup({ thresholds });
|
||||
|
||||
instance.onRemoveThreshold(thresholds[1]);
|
||||
instance.onRemoveThreshold(instance.state.thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 75, color: '#6ED0E0' },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -124,25 +123,25 @@ describe('Remove threshold', () => {
|
||||
describe('change threshold value', () => {
|
||||
it('should not change threshold at index 0', () => {
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 50, color: '#EAB839' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
];
|
||||
const { instance } = setup({ thresholds });
|
||||
|
||||
const mockEvent = ({ target: { value: '12' } } as any) as ChangeEvent<HTMLInputElement>;
|
||||
|
||||
instance.onChangeThresholdValue(mockEvent, thresholds[0]);
|
||||
instance.onChangeThresholdValue(mockEvent, instance.state.thresholds[0]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual(thresholds);
|
||||
expect(getCurrentThresholds(instance)).toEqual(thresholds);
|
||||
});
|
||||
|
||||
it('should update value', () => {
|
||||
const { instance } = setup();
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D', key: 1 },
|
||||
{ value: 50, color: '#EAB839', key: 2 },
|
||||
{ value: 75, color: '#6ED0E0', key: 3 },
|
||||
];
|
||||
|
||||
instance.state = {
|
||||
@@ -153,10 +152,10 @@ describe('change threshold value', () => {
|
||||
|
||||
instance.onChangeThresholdValue(mockEvent, thresholds[1]);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 78, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 78, color: '#EAB839' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -165,9 +164,9 @@ describe('on blur threshold value', () => {
|
||||
it('should resort rows and update indexes', () => {
|
||||
const { instance } = setup();
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 78, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ value: -Infinity, color: '#7EB26D', key: 1 },
|
||||
{ value: 78, color: '#EAB839', key: 2 },
|
||||
{ value: 75, color: '#6ED0E0', key: 3 },
|
||||
];
|
||||
|
||||
instance.setState({
|
||||
@@ -176,10 +175,10 @@ describe('on blur threshold value', () => {
|
||||
|
||||
instance.onBlur();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 75, color: '#6ED0E0' },
|
||||
{ index: 2, value: 78, color: '#EAB839' },
|
||||
expect(getCurrentThresholds(instance)).toEqual([
|
||||
{ value: -Infinity, color: '#7EB26D' },
|
||||
{ value: 75, color: '#6ED0E0' },
|
||||
{ value: 78, color: '#EAB839' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
import { Threshold } from '@grafana/data';
|
||||
import { Threshold, sortThresholds } from '@grafana/data';
|
||||
import { colors } from '../../utils';
|
||||
import { ThemeContext } from '../../themes';
|
||||
import { getColorFromHexRgbOrName } from '../../utils';
|
||||
@@ -13,115 +13,121 @@ export interface Props {
|
||||
}
|
||||
|
||||
interface State {
|
||||
thresholds: Threshold[];
|
||||
thresholds: ThresholdWithKey[];
|
||||
}
|
||||
|
||||
interface ThresholdWithKey extends Threshold {
|
||||
key: number;
|
||||
}
|
||||
|
||||
let counter = 100;
|
||||
|
||||
export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const addDefaultThreshold = this.props.thresholds.length === 0;
|
||||
const thresholds: Threshold[] = addDefaultThreshold
|
||||
? [{ index: 0, value: -Infinity, color: colors[0] }]
|
||||
: props.thresholds;
|
||||
const thresholds = props.thresholds
|
||||
? props.thresholds.map(t => {
|
||||
return {
|
||||
color: t.color,
|
||||
value: t.value === null ? -Infinity : t.value,
|
||||
key: counter++,
|
||||
};
|
||||
})
|
||||
: ([] as ThresholdWithKey[]);
|
||||
|
||||
let needsCallback = false;
|
||||
if (!thresholds.length) {
|
||||
thresholds.push({ value: -Infinity, color: colors[0], key: counter++ });
|
||||
needsCallback = true;
|
||||
} else {
|
||||
// First value is always base
|
||||
thresholds[0].value = -Infinity;
|
||||
}
|
||||
|
||||
// Update the state
|
||||
this.state = { thresholds };
|
||||
|
||||
if (addDefaultThreshold) {
|
||||
if (needsCallback) {
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onAddThreshold = (index: number) => {
|
||||
onAddThresholdAfter = (threshold: ThresholdWithKey) => {
|
||||
const { thresholds } = this.state;
|
||||
|
||||
const maxValue = 100;
|
||||
const minValue = 0;
|
||||
|
||||
if (index === 0) {
|
||||
return;
|
||||
let prev: ThresholdWithKey | undefined = undefined;
|
||||
let next: ThresholdWithKey | undefined = undefined;
|
||||
for (const t of thresholds) {
|
||||
if (prev && prev.key === threshold.key) {
|
||||
next = t;
|
||||
break;
|
||||
}
|
||||
prev = t;
|
||||
}
|
||||
|
||||
const newThresholds = thresholds.map(threshold => {
|
||||
if (threshold.index >= index) {
|
||||
const index = threshold.index + 1;
|
||||
threshold = { ...threshold, index };
|
||||
}
|
||||
return threshold;
|
||||
});
|
||||
const prevValue = prev && isFinite(prev.value) ? prev.value : minValue;
|
||||
const nextValue = next && isFinite(next.value) ? next.value : maxValue;
|
||||
|
||||
// Setting value to a value between the previous thresholds
|
||||
const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
|
||||
const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
|
||||
const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
|
||||
const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
|
||||
const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
|
||||
|
||||
// Set a color
|
||||
const color = colors.filter(c => !newThresholds.some(t => t.color === c))[1];
|
||||
const color = colors.filter(c => !thresholds.some(t => t.color === c))[1];
|
||||
const add = {
|
||||
value: prevValue + (nextValue - prevValue) / 2.0,
|
||||
color: color,
|
||||
key: counter++,
|
||||
};
|
||||
const newThresholds = [...thresholds, add];
|
||||
sortThresholds(newThresholds);
|
||||
|
||||
this.setState(
|
||||
{
|
||||
thresholds: this.sortThresholds([
|
||||
...newThresholds,
|
||||
{
|
||||
color,
|
||||
index,
|
||||
value: value as number,
|
||||
},
|
||||
]),
|
||||
thresholds: newThresholds,
|
||||
},
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onRemoveThreshold = (threshold: Threshold) => {
|
||||
if (threshold.index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
prevState => {
|
||||
const newThresholds = prevState.thresholds.map(t => {
|
||||
if (t.index > threshold.index) {
|
||||
const index = t.index - 1;
|
||||
t = { ...t, index };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
return {
|
||||
thresholds: newThresholds.filter(t => t !== threshold),
|
||||
};
|
||||
},
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChangeThresholdValue = (event: ChangeEvent<HTMLInputElement>, threshold: Threshold) => {
|
||||
if (threshold.index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRemoveThreshold = (threshold: ThresholdWithKey) => {
|
||||
const { thresholds } = this.state;
|
||||
if (!thresholds.length) {
|
||||
return;
|
||||
}
|
||||
// Don't remove index 0
|
||||
if (threshold.key === thresholds[0].key) {
|
||||
return;
|
||||
}
|
||||
this.setState(
|
||||
{
|
||||
thresholds: thresholds.filter(t => t.key !== threshold.key),
|
||||
},
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChangeThresholdValue = (event: ChangeEvent<HTMLInputElement>, threshold: ThresholdWithKey) => {
|
||||
const cleanValue = event.target.value.replace(/,/g, '.');
|
||||
const parsedValue = parseFloat(cleanValue);
|
||||
const value = isNaN(parsedValue) ? '' : parsedValue;
|
||||
|
||||
const newThresholds = thresholds.map(t => {
|
||||
if (t === threshold && t.index !== 0) {
|
||||
const thresholds = this.state.thresholds.map(t => {
|
||||
if (t.key === threshold.key) {
|
||||
t = { ...t, value: value as number };
|
||||
}
|
||||
|
||||
return t;
|
||||
});
|
||||
|
||||
this.setState({ thresholds: newThresholds });
|
||||
if (thresholds.length) {
|
||||
thresholds[0].value = -Infinity;
|
||||
}
|
||||
this.setState({ thresholds });
|
||||
};
|
||||
|
||||
onChangeThresholdColor = (threshold: Threshold, color: string) => {
|
||||
onChangeThresholdColor = (threshold: ThresholdWithKey, color: string) => {
|
||||
const { thresholds } = this.state;
|
||||
|
||||
const newThresholds = thresholds.map(t => {
|
||||
if (t === threshold) {
|
||||
if (t.key === threshold.key) {
|
||||
t = { ...t, color: color };
|
||||
}
|
||||
|
||||
@@ -137,30 +143,22 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
this.setState(prevState => {
|
||||
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
|
||||
let index = 0;
|
||||
sortThresholds.forEach(t => {
|
||||
t.index = index++;
|
||||
});
|
||||
|
||||
return { thresholds: sortThresholds };
|
||||
});
|
||||
|
||||
this.onChange();
|
||||
const thresholds = [...this.state.thresholds];
|
||||
sortThresholds(thresholds);
|
||||
this.setState(
|
||||
{
|
||||
thresholds,
|
||||
},
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChange = () => {
|
||||
this.props.onChange(this.state.thresholds);
|
||||
const { thresholds } = this.state;
|
||||
this.props.onChange(threshodsWithoutKey(thresholds));
|
||||
};
|
||||
|
||||
sortThresholds = (thresholds: Threshold[]) => {
|
||||
return thresholds.sort((t1, t2) => {
|
||||
return t1.value - t2.value;
|
||||
});
|
||||
};
|
||||
|
||||
renderInput = (threshold: Threshold) => {
|
||||
renderInput = (threshold: ThresholdWithKey) => {
|
||||
return (
|
||||
<div className="thresholds-row-input-inner">
|
||||
<span className="thresholds-row-input-inner-arrow" />
|
||||
@@ -175,12 +173,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{threshold.index === 0 && (
|
||||
{!isFinite(threshold.value) ? (
|
||||
<div className="thresholds-row-input-inner-value">
|
||||
<Input type="text" value="Base" readOnly />
|
||||
</div>
|
||||
)}
|
||||
{threshold.index > 0 && (
|
||||
) : (
|
||||
<>
|
||||
<div className="thresholds-row-input-inner-value">
|
||||
<Input
|
||||
@@ -189,7 +186,6 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onChangeThresholdValue(event, threshold)}
|
||||
value={threshold.value}
|
||||
onBlur={this.onBlur}
|
||||
readOnly={threshold.index === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="thresholds-row-input-inner-remove" onClick={() => this.onRemoveThreshold(threshold)}>
|
||||
@@ -212,13 +208,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
{thresholds
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((threshold, index) => {
|
||||
.map(threshold => {
|
||||
return (
|
||||
<div className="thresholds-row" key={`${threshold.index}-${index}`}>
|
||||
<div
|
||||
className="thresholds-row-add-button"
|
||||
onClick={() => this.onAddThreshold(threshold.index + 1)}
|
||||
>
|
||||
<div className="thresholds-row" key={`${threshold.key}`}>
|
||||
<div className="thresholds-row-add-button" onClick={() => this.onAddThresholdAfter(threshold)}>
|
||||
<i className="fa fa-plus" />
|
||||
</div>
|
||||
<div
|
||||
@@ -237,3 +230,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function threshodsWithoutKey(thresholds: ThresholdWithKey[]): Threshold[] {
|
||||
return thresholds.map(t => {
|
||||
const { key, ...rest } = t;
|
||||
return rest; // everything except key
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ exports[`Render should render with base threshold 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"color": "#7EB26D",
|
||||
"index": 0,
|
||||
"value": -Infinity,
|
||||
},
|
||||
],
|
||||
@@ -48,7 +47,7 @@ exports[`Render should render with base threshold 1`] = `
|
||||
>
|
||||
<div
|
||||
className="thresholds-row"
|
||||
key="0-0"
|
||||
key="100"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-add-button"
|
||||
|
||||
@@ -8,13 +8,13 @@ import { TimePickerPopover } from './TimePickerPopover';
|
||||
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
|
||||
// Utils & Services
|
||||
import { isDateTime } from '@grafana/data';
|
||||
import { isDateTime, DateTime } from '@grafana/data';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { rawToTimeRange } from './time';
|
||||
|
||||
// Types
|
||||
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT } from '@grafana/data';
|
||||
import { SelectOptionItem } from '../Select/Select';
|
||||
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue } from '@grafana/data';
|
||||
import { isMathString } from '@grafana/data/src/utils/datemath';
|
||||
|
||||
export interface Props {
|
||||
value: TimeRange;
|
||||
@@ -77,7 +77,7 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
isCustomOpen: false,
|
||||
};
|
||||
|
||||
mapTimeOptionsToSelectOptionItems = (selectOptions: TimeOption[]) => {
|
||||
mapTimeOptionsToSelectableValues = (selectOptions: TimeOption[]) => {
|
||||
const options = selectOptions.map(timeOption => {
|
||||
return {
|
||||
label: timeOption.display,
|
||||
@@ -93,7 +93,7 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
return options;
|
||||
};
|
||||
|
||||
onSelectChanged = (item: SelectOptionItem<TimeOption>) => {
|
||||
onSelectChanged = (item: SelectableValue<TimeOption>) => {
|
||||
const { onChange, timeZone } = this.props;
|
||||
|
||||
if (item.value && item.value.from === 'custom') {
|
||||
@@ -122,15 +122,23 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
render() {
|
||||
const { selectOptions: selectTimeOptions, value, onMoveBackward, onMoveForward, onZoom, timeZone } = this.props;
|
||||
const { isCustomOpen } = this.state;
|
||||
const options = this.mapTimeOptionsToSelectOptionItems(selectTimeOptions);
|
||||
const options = this.mapTimeOptionsToSelectableValues(selectTimeOptions);
|
||||
const currentOption = options.find(item => isTimeOptionEqualToTimeRange(item.value, value));
|
||||
const rangeString = rangeUtil.describeTimeRange(value.raw);
|
||||
|
||||
const isUTC = timeZone === 'utc';
|
||||
|
||||
const adjustedTime = (time: DateTime) => (isUTC ? time.utc() : time.local()) || null;
|
||||
const adjustedTimeRange = {
|
||||
to: isMathString(value.raw.to) ? value.raw.to : adjustedTime(value.to),
|
||||
from: isMathString(value.raw.from) ? value.raw.from : adjustedTime(value.from),
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange(adjustedTimeRange);
|
||||
|
||||
const label = (
|
||||
<>
|
||||
{isCustomOpen && <span>Custom time range</span>}
|
||||
{!isCustomOpen && <span>{rangeString}</span>}
|
||||
{timeZone === 'utc' && <span className="time-picker-utc">UTC</span>}
|
||||
{isUTC && <span className="time-picker-utc">UTC</span>}
|
||||
</>
|
||||
);
|
||||
const isAbsolute = isDateTime(value.raw.to);
|
||||
@@ -148,6 +156,7 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
value={currentOption}
|
||||
label={label}
|
||||
options={options}
|
||||
maxMenuHeight={600}
|
||||
onChange={this.onSelectChanged}
|
||||
iconClass={'fa fa-clock-o fa-fw'}
|
||||
tooltipContent={<TimePickerTooltipContent timeRange={value} />}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
.time-picker-popover {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-around;
|
||||
border: 1px solid $popover-border-color;
|
||||
border-radius: $border-radius;
|
||||
@@ -31,41 +30,41 @@
|
||||
max-width: 600px;
|
||||
top: 41px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.time-picker-popover-body {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-around;
|
||||
padding: $space-md;
|
||||
padding-bottom: 0;
|
||||
.time-picker-popover-body {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-around;
|
||||
padding: $space-md;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.time-picker-popover-title {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semi-bold;
|
||||
}
|
||||
|
||||
.time-picker-popover-body-custom-ranges:first-child {
|
||||
margin-right: $space-md;
|
||||
}
|
||||
|
||||
.time-picker-popover-body-custom-ranges-input {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
margin-bottom: $space-sm;
|
||||
|
||||
.time-picker-input-error {
|
||||
box-shadow: inset 0 0px 5px $red;
|
||||
}
|
||||
}
|
||||
|
||||
.time-picker-popover-title {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semi-bold;
|
||||
}
|
||||
|
||||
.time-picker-popover-body-custom-ranges:first-child {
|
||||
margin-right: $space-md;
|
||||
}
|
||||
|
||||
.time-picker-popover-body-custom-ranges-input {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
margin-bottom: $space-sm;
|
||||
|
||||
.time-picker-input-error {
|
||||
box-shadow: inset 0 0px 5px $red;
|
||||
}
|
||||
}
|
||||
|
||||
.time-picker-popover-footer {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: center;
|
||||
padding: $space-md;
|
||||
}
|
||||
.time-picker-popover-footer {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: center;
|
||||
padding: $space-md;
|
||||
}
|
||||
|
||||
.time-picker-popover-header {
|
||||
|
||||
@@ -9,7 +9,7 @@ export * from './Button/Button';
|
||||
export { ButtonVariant } from './Button/AbstractButton';
|
||||
|
||||
// Select
|
||||
export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
|
||||
export { Select, AsyncSelect } from './Select/Select';
|
||||
export { IndicatorsContainer } from './Select/IndicatorsContainer';
|
||||
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
||||
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
||||
|
||||
@@ -77,6 +77,13 @@ interface PluginMetaInfoLink {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginBuildInfo {
|
||||
time?: number;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export interface PluginMetaInfo {
|
||||
author: {
|
||||
name: string;
|
||||
@@ -88,6 +95,7 @@ export interface PluginMetaInfo {
|
||||
large: string;
|
||||
small: string;
|
||||
};
|
||||
build?: PluginBuildInfo;
|
||||
screenshots: any[];
|
||||
updated: string;
|
||||
version: string;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const deprecationWarning = (file: string, oldName: string, newName: string) => {
|
||||
const message = `[Deprecation warning] ${file}: ${oldName} is deprecated. Use ${newName} instead`;
|
||||
export const deprecationWarning = (file: string, oldName: string, newName?: string) => {
|
||||
let message = `[Deprecation warning] ${file}: ${oldName} is deprecated`;
|
||||
if (newName) {
|
||||
message += `. Use ${newName} instead`;
|
||||
}
|
||||
console.warn(message);
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Format value', () => {
|
||||
it('should return if value isNaN', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = 'N/A';
|
||||
const instance = getDisplayProcessor({ mappings: valueMappings });
|
||||
const instance = getDisplayProcessor({ field: { mappings: valueMappings } });
|
||||
|
||||
const result = instance(value);
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('Format value', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = '6';
|
||||
|
||||
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
|
||||
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
|
||||
|
||||
const result = instance(value);
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('Format value', () => {
|
||||
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
|
||||
];
|
||||
const value = '10';
|
||||
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
|
||||
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
|
||||
|
||||
const result = instance(value);
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('Format value', () => {
|
||||
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
|
||||
];
|
||||
const value = '11';
|
||||
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
|
||||
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
|
||||
|
||||
expect(instance(value).text).toEqual('1-20');
|
||||
});
|
||||
|
||||
@@ -7,16 +7,13 @@ import { getColorFromHexRgbOrName } from './namedColorsPalette';
|
||||
|
||||
// Types
|
||||
import { DecimalInfo, DisplayValue, GrafanaTheme, GrafanaThemeType, DecimalCount } from '../types';
|
||||
import { DateTime, dateTime, Threshold, ValueMapping, getMappedValue, Field } from '@grafana/data';
|
||||
import { DateTime, dateTime, Threshold, getMappedValue, Field } from '@grafana/data';
|
||||
|
||||
export type DisplayProcessor = (value: any) => DisplayValue;
|
||||
|
||||
export interface DisplayValueOptions {
|
||||
field?: Partial<Field>;
|
||||
|
||||
mappings?: ValueMapping[];
|
||||
thresholds?: Threshold[];
|
||||
|
||||
// Alternative to empty string
|
||||
noValue?: string;
|
||||
|
||||
@@ -31,7 +28,8 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce
|
||||
const formatFunc = getValueFormat(field.unit || 'none');
|
||||
|
||||
return (value: any) => {
|
||||
const { mappings, thresholds, theme } = options;
|
||||
const { theme } = options;
|
||||
const { mappings, thresholds } = field;
|
||||
let color;
|
||||
|
||||
let text = _.toString(value);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getFieldProperties, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
|
||||
import { FieldType, ReducerID } from '@grafana/data';
|
||||
import { FieldType, ReducerID, Threshold } from '@grafana/data';
|
||||
import { GrafanaThemeType } from '../types/theme';
|
||||
import { getTheme } from '../themes/index';
|
||||
|
||||
@@ -55,8 +55,6 @@ describe('FieldDisplay', () => {
|
||||
},
|
||||
fieldOptions: {
|
||||
calcs: [],
|
||||
mappings: [],
|
||||
thresholds: [],
|
||||
override: {},
|
||||
defaults: {},
|
||||
},
|
||||
@@ -68,8 +66,6 @@ describe('FieldDisplay', () => {
|
||||
...options,
|
||||
fieldOptions: {
|
||||
calcs: [ReducerID.first],
|
||||
mappings: [],
|
||||
thresholds: [],
|
||||
override: {},
|
||||
defaults: {
|
||||
title: '$__cell_0 * $__field_name * $__series_name',
|
||||
@@ -88,8 +84,6 @@ describe('FieldDisplay', () => {
|
||||
...options,
|
||||
fieldOptions: {
|
||||
calcs: [ReducerID.last],
|
||||
mappings: [],
|
||||
thresholds: [],
|
||||
override: {},
|
||||
defaults: {},
|
||||
},
|
||||
@@ -104,8 +98,6 @@ describe('FieldDisplay', () => {
|
||||
values: true, //
|
||||
limit: 1000,
|
||||
calcs: [],
|
||||
mappings: [],
|
||||
thresholds: [],
|
||||
override: {},
|
||||
defaults: {},
|
||||
},
|
||||
@@ -120,12 +112,27 @@ describe('FieldDisplay', () => {
|
||||
values: true, //
|
||||
limit: 2,
|
||||
calcs: [],
|
||||
mappings: [],
|
||||
thresholds: [],
|
||||
override: {},
|
||||
defaults: {},
|
||||
},
|
||||
});
|
||||
expect(display.map(v => v.display.numeric)).toEqual([1, 3]); // First 2 are from the first field
|
||||
});
|
||||
|
||||
it('should restore -Infinity value for base threshold', () => {
|
||||
const field = getFieldProperties({
|
||||
thresholds: [
|
||||
({
|
||||
color: '#73BF69',
|
||||
value: null,
|
||||
} as unknown) as Threshold,
|
||||
{
|
||||
color: '#F2495C',
|
||||
value: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(field.thresholds!.length).toEqual(2);
|
||||
expect(field.thresholds![0].value).toBe(-Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,7 @@ import toString from 'lodash/toString';
|
||||
import { DisplayValue, GrafanaTheme, InterpolateFunction, ScopedVars, GraphSeriesValue } from '../types/index';
|
||||
import { getDisplayProcessor } from './displayValue';
|
||||
import { getFlotPairs } from './flotPairs';
|
||||
import {
|
||||
ValueMapping,
|
||||
Threshold,
|
||||
ReducerID,
|
||||
reduceField,
|
||||
FieldType,
|
||||
NullValueMode,
|
||||
DataFrame,
|
||||
Field,
|
||||
} from '@grafana/data';
|
||||
import { ReducerID, reduceField, FieldType, NullValueMode, DataFrame, Field } from '@grafana/data';
|
||||
|
||||
export interface FieldDisplayOptions {
|
||||
values?: boolean; // If true show each row value
|
||||
@@ -22,10 +13,6 @@ export interface FieldDisplayOptions {
|
||||
|
||||
defaults: Partial<Field>; // Use these values unless otherwise stated
|
||||
override: Partial<Field>; // Set these values regardless of the source
|
||||
|
||||
// Could these be data driven also?
|
||||
thresholds: Threshold[];
|
||||
mappings: ValueMapping[];
|
||||
}
|
||||
|
||||
export const VAR_SERIES_NAME = '__series_name';
|
||||
@@ -127,8 +114,6 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
|
||||
const display = getDisplayProcessor({
|
||||
field,
|
||||
mappings: fieldOptions.mappings,
|
||||
thresholds: fieldOptions.thresholds,
|
||||
theme: options.theme,
|
||||
});
|
||||
|
||||
@@ -263,6 +248,11 @@ export function getFieldProperties(...props: PartialField[]): Field {
|
||||
field = applyFieldProperties(field, props[i]);
|
||||
}
|
||||
|
||||
// First value is always -Infinity
|
||||
if (field.thresholds && field.thresholds.length) {
|
||||
field.thresholds[0].value = -Infinity;
|
||||
}
|
||||
|
||||
// Verify that max > min
|
||||
if (field.hasOwnProperty('min') && field.hasOwnProperty('max') && field.min! > field.max!) {
|
||||
return {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"rootDirs": [".", "stories"],
|
||||
"module": "esnext",
|
||||
"outDir": "compiled",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"typeRoots": ["./node_modules/@types", "types"],
|
||||
"skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors,
|
||||
"removeComments": false
|
||||
"declarationDir": "dist",
|
||||
"outDir": "compiled"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/tsconfig.json
Normal file
13
packages/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors,
|
||||
"removeComments": false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BASE_IMAGE=ubuntu:latest
|
||||
ARG BASE_IMAGE=ubuntu:18.04
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz"
|
||||
@@ -12,7 +12,7 @@ COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz
|
||||
# Change to tar xfzv to make tar print every file it extracts
|
||||
RUN mkdir /tmp/grafana && tar xfz /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana
|
||||
|
||||
ARG BASE_IMAGE=ubuntu:latest
|
||||
ARG BASE_IMAGE=ubuntu:18.04
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG GF_UID="472"
|
||||
|
||||
@@ -59,14 +59,16 @@ docker_tag_all () {
|
||||
fi
|
||||
}
|
||||
|
||||
docker_build "ubuntu:latest" "grafana-latest.linux-x64.tar.gz" "${_docker_repo}:${_grafana_version}"
|
||||
docker_build "ubuntu:18.04" "grafana-latest.linux-x64.tar.gz" "${_docker_repo}:${_grafana_version}"
|
||||
if [ $BUILD_FAST = "0" ]; then
|
||||
docker_build "arm32v7/ubuntu:latest" "grafana-latest.linux-armv7.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
|
||||
docker_build "arm64v8/ubuntu:latest" "grafana-latest.linux-arm64.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
|
||||
docker_build "arm32v7/ubuntu:18.04" "grafana-latest.linux-armv7.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
|
||||
docker_build "arm64v8/ubuntu:18.04" "grafana-latest.linux-arm64.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
|
||||
fi
|
||||
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
|
||||
if echo "$_grafana_tag" | grep -q "^v"; then
|
||||
docker_tag_all "${_docker_repo}" "latest"
|
||||
# Create the expected tag for running the end to end tests successfully
|
||||
docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_tag}"
|
||||
else
|
||||
docker_tag_all "${_docker_repo}" "master"
|
||||
docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_version}"
|
||||
|
||||
@@ -38,8 +38,14 @@ if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"
|
||||
echo "pushing ${_docker_repo}:latest"
|
||||
docker_push_all "${_docker_repo}" "latest"
|
||||
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
||||
# Push to the grafana-dev repository with the expected tag
|
||||
# for running the end to end tests successfully
|
||||
docker push "grafana/grafana-dev:${_grafana_tag}"
|
||||
elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
|
||||
docker_push_all "${_docker_repo}" "${_grafana_version}"
|
||||
# Push to the grafana-dev repository with the expected tag
|
||||
# for running the end to end tests successfully
|
||||
docker push "grafana/grafana-dev:${_grafana_tag}"
|
||||
elif echo "$_grafana_tag" | grep -q "master"; then
|
||||
docker_push_all "${_docker_repo}" "master"
|
||||
docker push "grafana/grafana-dev:${_grafana_version}"
|
||||
|
||||
@@ -34,7 +34,7 @@ func AdminCreateUser(c *models.ReqContext, form dtos.AdminCreateUserForm) {
|
||||
return
|
||||
}
|
||||
|
||||
metrics.M_Api_Admin_User_Create.Inc()
|
||||
metrics.MApiAdminUserCreate.Inc()
|
||||
|
||||
user := cmd.Result
|
||||
|
||||
|
||||
@@ -93,6 +93,26 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) fakeReqNoAssertions(method, url string) *scenarioContext {
|
||||
sc.resp = httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
sc.req = req
|
||||
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) fakeReqNoAssertionsWithCookie(method, url string, cookie http.Cookie) *scenarioContext {
|
||||
sc.resp = httptest.NewRecorder()
|
||||
http.SetCookie(sc.resp, &cookie)
|
||||
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header = http.Header{"Cookie": sc.resp.Header()["Set-Cookie"]}
|
||||
|
||||
sc.req = req
|
||||
|
||||
return sc
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
m *macaron.Macaron
|
||||
context *m.ReqContext
|
||||
|
||||
@@ -133,7 +133,7 @@ func (hs *HTTPServer) GetDashboard(c *m.ReqContext) Response {
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Get)
|
||||
c.TimeRequest(metrics.MApiDashboardGet)
|
||||
return JSON(200, dto)
|
||||
}
|
||||
|
||||
@@ -278,12 +278,11 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
|
||||
inFolder := cmd.FolderId > 0
|
||||
err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
|
||||
if err != nil {
|
||||
hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
|
||||
return Error(500, "Failed to make user admin of dashboard", err)
|
||||
hs.log.Error("Could not make user admin", "dashboard", dashboard.Title, "user", c.SignedInUser.UserId, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Save)
|
||||
c.TimeRequest(metrics.MApiDashboardSave)
|
||||
return JSON(200, util.DynMap{
|
||||
"status": "success",
|
||||
"slug": dashboard.Slug,
|
||||
|
||||
@@ -97,7 +97,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma
|
||||
cmd.ExternalDeleteUrl = response.DeleteUrl
|
||||
cmd.Dashboard = simplejson.New()
|
||||
|
||||
metrics.M_Api_Dashboard_Snapshot_External.Inc()
|
||||
metrics.MApiDashboardSnapshotExternal.Inc()
|
||||
} else {
|
||||
if cmd.Key == "" {
|
||||
cmd.Key = util.GetRandomString(32)
|
||||
@@ -109,7 +109,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma
|
||||
|
||||
url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
|
||||
|
||||
metrics.M_Api_Dashboard_Snapshot_Create.Inc()
|
||||
metrics.MApiDashboardSnapshotCreate.Inc()
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
@@ -154,7 +154,7 @@ func GetDashboardSnapshot(c *m.ReqContext) {
|
||||
},
|
||||
}
|
||||
|
||||
metrics.M_Api_Dashboard_Snapshot_Get.Inc()
|
||||
metrics.MApiDashboardSnapshotGet.Inc()
|
||||
|
||||
c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
c.JSON(200, dto)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
|
||||
c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
|
||||
c.TimeRequest(metrics.MDataSourceProxyReqTimer)
|
||||
|
||||
dsId := c.ParamsInt64(":id")
|
||||
ds, err := hs.DatasourceCache.GetDatasource(dsId, c.SignedInUser, c.SkipCache)
|
||||
|
||||
@@ -64,7 +64,6 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R
|
||||
if hs.Cfg.EditorsCanAdmin {
|
||||
if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
|
||||
hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
|
||||
return Error(500, "Failed to make user admin of folder", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,8 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}).
|
||||
promhttp.
|
||||
HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}).
|
||||
ServeHTTP(ctx.Resp, ctx.Req.Request)
|
||||
}
|
||||
|
||||
|
||||
146
pkg/api/index.go
146
pkg/api/index.go
@@ -242,74 +242,69 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsGrafanaAdmin || c.OrgRole == m.ROLE_ADMIN {
|
||||
cfgNode := &dtos.NavLink{
|
||||
Id: "cfg",
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "gicon gicon-cog",
|
||||
Url: setting.AppSubUrl + "/datasources",
|
||||
Children: []*dtos.NavLink{
|
||||
{
|
||||
Text: "Data Sources",
|
||||
Icon: "gicon gicon-datasources",
|
||||
Description: "Add and configure data sources",
|
||||
Id: "datasources",
|
||||
Url: setting.AppSubUrl + "/datasources",
|
||||
},
|
||||
{
|
||||
Text: "Users",
|
||||
Id: "users",
|
||||
Description: "Manage org members",
|
||||
Icon: "gicon gicon-user",
|
||||
Url: setting.AppSubUrl + "/org/users",
|
||||
},
|
||||
{
|
||||
Text: "Teams",
|
||||
Id: "teams",
|
||||
Description: "Manage org groups",
|
||||
Icon: "gicon gicon-team",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
},
|
||||
{
|
||||
Text: "Plugins",
|
||||
Id: "plugins",
|
||||
Description: "View and configure plugins",
|
||||
Icon: "gicon gicon-plugins",
|
||||
Url: setting.AppSubUrl + "/plugins",
|
||||
},
|
||||
{
|
||||
Text: "Preferences",
|
||||
Id: "org-settings",
|
||||
Description: "Organization preferences",
|
||||
Icon: "gicon gicon-preferences",
|
||||
Url: setting.AppSubUrl + "/org",
|
||||
},
|
||||
configNodes := []*dtos.NavLink{}
|
||||
|
||||
{
|
||||
Text: "API Keys",
|
||||
Id: "apikeys",
|
||||
Description: "Create & manage API keys",
|
||||
Icon: "gicon gicon-apikeys",
|
||||
Url: setting.AppSubUrl + "/org/apikeys",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if c.OrgRole != m.ROLE_ADMIN {
|
||||
cfgNode = &dtos.NavLink{
|
||||
Id: "cfg",
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "gicon gicon-cog",
|
||||
Url: setting.AppSubUrl + "/admin/users",
|
||||
Children: make([]*dtos.NavLink, 0),
|
||||
}
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, cfgNode)
|
||||
if c.OrgRole == m.ROLE_ADMIN {
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Data Sources",
|
||||
Icon: "gicon gicon-datasources",
|
||||
Description: "Add and configure data sources",
|
||||
Id: "datasources",
|
||||
Url: setting.AppSubUrl + "/datasources",
|
||||
})
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Users",
|
||||
Id: "users",
|
||||
Description: "Manage org members",
|
||||
Icon: "gicon gicon-user",
|
||||
Url: setting.AppSubUrl + "/org/users",
|
||||
})
|
||||
}
|
||||
|
||||
if c.OrgRole == m.ROLE_ADMIN || hs.Cfg.EditorsCanAdmin {
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Teams",
|
||||
Id: "teams",
|
||||
Description: "Manage org groups",
|
||||
Icon: "gicon gicon-team",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
})
|
||||
}
|
||||
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Plugins",
|
||||
Id: "plugins",
|
||||
Description: "View and configure plugins",
|
||||
Icon: "gicon gicon-plugins",
|
||||
Url: setting.AppSubUrl + "/plugins",
|
||||
})
|
||||
|
||||
if c.OrgRole == m.ROLE_ADMIN {
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Preferences",
|
||||
Id: "org-settings",
|
||||
Description: "Organization preferences",
|
||||
Icon: "gicon gicon-preferences",
|
||||
Url: setting.AppSubUrl + "/org",
|
||||
})
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "API Keys",
|
||||
Id: "apikeys",
|
||||
Description: "Create & manage API keys",
|
||||
Icon: "gicon gicon-apikeys",
|
||||
Url: setting.AppSubUrl + "/org/apikeys",
|
||||
})
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Id: "cfg",
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "gicon gicon-cog",
|
||||
Url: configNodes[0].Url,
|
||||
Children: configNodes,
|
||||
})
|
||||
|
||||
if c.IsGrafanaAdmin {
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Server Admin",
|
||||
@@ -327,27 +322,6 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
||||
})
|
||||
}
|
||||
|
||||
if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
|
||||
cfgNode := &dtos.NavLink{
|
||||
Id: "cfg",
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "gicon gicon-cog",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
Children: []*dtos.NavLink{
|
||||
{
|
||||
Text: "Teams",
|
||||
Id: "teams",
|
||||
Description: "Manage org groups",
|
||||
Icon: "gicon gicon-team",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, cfgNode)
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Help",
|
||||
SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),
|
||||
|
||||
@@ -21,8 +21,14 @@ const (
|
||||
LoginErrorCookieName = "login_error"
|
||||
)
|
||||
|
||||
var setIndexViewData = (*HTTPServer).setIndexViewData
|
||||
|
||||
var getViewIndex = func() string {
|
||||
return ViewIndex
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) LoginView(c *models.ReqContext) {
|
||||
viewData, err := hs.setIndexViewData(c)
|
||||
viewData, err := setIndexViewData(hs, c)
|
||||
if err != nil {
|
||||
c.Handle(500, "Failed to get settings", err)
|
||||
return
|
||||
@@ -38,11 +44,17 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
|
||||
viewData.Settings["loginHint"] = setting.LoginHint
|
||||
viewData.Settings["passwordHint"] = setting.PasswordHint
|
||||
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
|
||||
viewData.Settings["samlEnabled"] = hs.Cfg.SAMLEnabled
|
||||
viewData.Settings["samlEnabled"] = setting.IsEnterprise && hs.Cfg.SAMLEnabled
|
||||
|
||||
if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
|
||||
//this cookie is only set whenever an OAuth login fails
|
||||
//therefore the loginError should be passed to the view data
|
||||
//and the view should return immediately before attempting
|
||||
//to login again via OAuth and enter to a redirect loop
|
||||
deleteCookie(c, LoginErrorCookieName)
|
||||
viewData.Settings["loginError"] = loginError
|
||||
c.HTML(200, getViewIndex(), viewData)
|
||||
return
|
||||
}
|
||||
|
||||
if tryOAuthAutoLogin(c) {
|
||||
@@ -69,7 +81,7 @@ func tryOAuthAutoLogin(c *models.ReqContext) bool {
|
||||
}
|
||||
oauthInfos := setting.OAuthService.OAuthInfos
|
||||
if len(oauthInfos) != 1 {
|
||||
log.Warn("Skipping OAuth auto login because multiple OAuth providers are configured.")
|
||||
log.Warn("Skipping OAuth auto login because multiple OAuth providers are configured")
|
||||
return false
|
||||
}
|
||||
for key := range setting.OAuthService.OAuthInfos {
|
||||
@@ -102,12 +114,16 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(authQuery); err != nil {
|
||||
e401 := Error(401, "Invalid username or password", err)
|
||||
if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts {
|
||||
return Error(401, "Invalid username or password", err)
|
||||
return e401
|
||||
}
|
||||
|
||||
// Do not expose disabled status,
|
||||
// just show incorrect user credentials error (see #17947)
|
||||
if err == login.ErrUserDisabled {
|
||||
return Error(401, "User is disabled", err)
|
||||
hs.log.Warn("User is disabled", "user", cmd.User)
|
||||
return e401
|
||||
}
|
||||
|
||||
return Error(500, "Error while trying to authenticate user", err)
|
||||
@@ -126,7 +142,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res
|
||||
c.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/")
|
||||
}
|
||||
|
||||
metrics.M_Api_Login_Post.Inc()
|
||||
metrics.MApiLoginPost.Inc()
|
||||
return JSON(200, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||
if code == "" {
|
||||
state := GenStateString()
|
||||
hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
|
||||
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
|
||||
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60, http.SameSiteLaxMode)
|
||||
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
|
||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
|
||||
} else {
|
||||
@@ -73,7 +73,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||
|
||||
// delete cookie
|
||||
ctx.Resp.Header().Del("Set-Cookie")
|
||||
hs.deleteCookie(ctx.Resp, OauthStateCookieName)
|
||||
hs.deleteCookie(ctx.Resp, OauthStateCookieName, http.SameSiteLaxMode)
|
||||
|
||||
if cookieState == "" {
|
||||
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
|
||||
@@ -191,15 +191,18 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Do not expose disabled status,
|
||||
// just show incorrect user credentials error (see #17947)
|
||||
if cmd.Result.IsDisabled {
|
||||
hs.redirectWithError(ctx, login.ErrUserDisabled)
|
||||
oauthLogger.Warn("User is disabled", "user", cmd.Result.Login)
|
||||
hs.redirectWithError(ctx, login.ErrInvalidCredentials)
|
||||
return
|
||||
}
|
||||
|
||||
// login
|
||||
hs.loginUserWithUser(cmd.Result, ctx)
|
||||
|
||||
metrics.M_Api_Login_OAuth.Inc()
|
||||
metrics.MApiLoginOAuth.Inc()
|
||||
|
||||
if redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")); len(redirectTo) > 0 {
|
||||
ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/")
|
||||
@@ -210,11 +213,11 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||
ctx.Redirect(setting.AppSubUrl + "/")
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
|
||||
hs.writeCookie(w, name, "", -1)
|
||||
func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string, sameSite http.SameSite) {
|
||||
hs.writeCookie(w, name, "", -1, sameSite)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
|
||||
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int, sameSite http.SameSite) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
MaxAge: maxAge,
|
||||
@@ -222,7 +225,7 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri
|
||||
HttpOnly: true,
|
||||
Path: setting.AppSubUrl + "/",
|
||||
Secure: hs.Cfg.CookieSecure,
|
||||
SameSite: hs.Cfg.CookieSameSite,
|
||||
SameSite: sameSite,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
135
pkg/api/login_test.go
Normal file
135
pkg/api/login_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mockSetIndexViewData() {
|
||||
setIndexViewData = func(*HTTPServer, *models.ReqContext) (*dtos.IndexViewData, error) {
|
||||
data := &dtos.IndexViewData{
|
||||
User: &dtos.CurrentUser{},
|
||||
Settings: map[string]interface{}{},
|
||||
NavTree: []*dtos.NavLink{},
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetSetIndexViewData() {
|
||||
setIndexViewData = (*HTTPServer).setIndexViewData
|
||||
}
|
||||
|
||||
func mockViewIndex() {
|
||||
getViewIndex = func() string {
|
||||
return "index-template"
|
||||
}
|
||||
}
|
||||
|
||||
func resetViewIndex() {
|
||||
getViewIndex = func() string {
|
||||
return ViewIndex
|
||||
}
|
||||
}
|
||||
|
||||
func getBody(resp *httptest.ResponseRecorder) (string, error) {
|
||||
responseData, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(responseData), nil
|
||||
}
|
||||
|
||||
func TestLoginErrorCookieApiEndpoint(t *testing.T) {
|
||||
mockSetIndexViewData()
|
||||
defer resetSetIndexViewData()
|
||||
|
||||
mockViewIndex()
|
||||
defer resetViewIndex()
|
||||
|
||||
sc := setupScenarioContext("/login")
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) {
|
||||
hs.LoginView(c)
|
||||
})
|
||||
|
||||
setting.OAuthService = &setting.OAuther{}
|
||||
setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo)
|
||||
setting.LoginCookieName = "grafana_session"
|
||||
setting.SecretKey = "login_testing"
|
||||
|
||||
setting.OAuthService = &setting.OAuther{}
|
||||
setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo)
|
||||
setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{
|
||||
ClientId: "fake",
|
||||
ClientSecret: "fakefake",
|
||||
Enabled: true,
|
||||
AllowSignup: true,
|
||||
Name: "github",
|
||||
}
|
||||
setting.OAuthAutoLogin = true
|
||||
|
||||
oauthError := errors.New("User not a member of one of the required organizations")
|
||||
encryptedError, _ := util.Encrypt([]byte(oauthError.Error()), setting.SecretKey)
|
||||
cookie := http.Cookie{
|
||||
Name: LoginErrorCookieName,
|
||||
MaxAge: 60,
|
||||
Value: hex.EncodeToString(encryptedError),
|
||||
HttpOnly: true,
|
||||
Path: setting.AppSubUrl + "/",
|
||||
Secure: hs.Cfg.CookieSecure,
|
||||
SameSite: hs.Cfg.CookieSameSite,
|
||||
}
|
||||
sc.m.Get(sc.url, sc.defaultHandler)
|
||||
sc.fakeReqNoAssertionsWithCookie("GET", sc.url, cookie).exec()
|
||||
assert.Equal(t, sc.resp.Code, 200)
|
||||
|
||||
responseString, err := getBody(sc.resp)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, strings.Contains(responseString, oauthError.Error()))
|
||||
}
|
||||
|
||||
func TestLoginOAuthRedirect(t *testing.T) {
|
||||
mockSetIndexViewData()
|
||||
defer resetSetIndexViewData()
|
||||
|
||||
sc := setupScenarioContext("/login")
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
|
||||
sc.defaultHandler = Wrap(func(c *models.ReqContext) {
|
||||
hs.LoginView(c)
|
||||
})
|
||||
|
||||
setting.OAuthService = &setting.OAuther{}
|
||||
setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo)
|
||||
setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{
|
||||
ClientId: "fake",
|
||||
ClientSecret: "fakefake",
|
||||
Enabled: true,
|
||||
AllowSignup: true,
|
||||
Name: "github",
|
||||
}
|
||||
setting.OAuthAutoLogin = true
|
||||
sc.m.Get(sc.url, sc.defaultHandler)
|
||||
sc.fakeReqNoAssertions("GET", sc.url).exec()
|
||||
|
||||
assert.Equal(t, sc.resp.Code, 307)
|
||||
location, ok := sc.resp.Header()["Location"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, location[0], "/login/github")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func CreateOrg(c *m.ReqContext, cmd m.CreateOrgCommand) Response {
|
||||
return Error(500, "Failed to create organization", err)
|
||||
}
|
||||
|
||||
metrics.M_Api_Org_Create.Inc()
|
||||
metrics.MApiOrgCreate.Inc()
|
||||
|
||||
return JSON(200, &util.DynMap{
|
||||
"orgId": cmd.Result.Id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user