mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
161 Commits
zoltan/pos
...
v7.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2c78d3f8 | ||
|
|
010f20c1c8 | ||
|
|
0587281d99 | ||
|
|
beff0fbda8 | ||
|
|
d0f940c268 | ||
|
|
59b730c372 | ||
|
|
d4090a1d12 | ||
|
|
e106dfdaad | ||
|
|
0423d54afd | ||
|
|
3457c0aa76 | ||
|
|
a41238959c | ||
|
|
111f5234a2 | ||
|
|
29e75ad97b | ||
|
|
0cc1a4369a | ||
|
|
ef677f34b1 | ||
|
|
064546f382 | ||
|
|
b85da0cc0a | ||
|
|
8b4c370752 | ||
|
|
44008445b8 | ||
|
|
65d8420007 | ||
|
|
0e60372c09 | ||
|
|
dafb37e819 | ||
|
|
170b0f329c | ||
|
|
1c4712aeeb | ||
|
|
c4774ec6ae | ||
|
|
dda38c55e5 | ||
|
|
d087a69e2e | ||
|
|
1adce1fcfc | ||
|
|
dbad6a9182 | ||
|
|
3efcb19cb2 | ||
|
|
6e68a7ac59 | ||
|
|
b0f6cf8669 | ||
|
|
e42d6931c4 | ||
|
|
c8a7044504 | ||
|
|
5f20a59b85 | ||
|
|
07649d1313 | ||
|
|
994326759e | ||
|
|
2c3eb7ddae | ||
|
|
b9d92efdff | ||
|
|
ba9ab09ad6 | ||
|
|
14a89707e6 | ||
|
|
fada9fcbff | ||
|
|
762c694092 | ||
|
|
02cd1faf88 | ||
|
|
368bd2d7bb | ||
|
|
97f8bc7250 | ||
|
|
73f933b691 | ||
|
|
7924e6fec6 | ||
|
|
cefbec44fb | ||
|
|
a200db0d05 | ||
|
|
989f6933cd | ||
|
|
4d74e49285 | ||
|
|
b98b3cf260 | ||
|
|
9a4fe5d17f | ||
|
|
840b55926b | ||
|
|
7e7f90ed6c | ||
|
|
68597e8cfb | ||
|
|
a4f8c56a0a | ||
|
|
e7ea30c8fd | ||
|
|
b4181a077f | ||
|
|
bc1f03403b | ||
|
|
8b79f0d7c5 | ||
|
|
8c52f69395 | ||
|
|
c2203b9859 | ||
|
|
089d84cf5c | ||
|
|
0c3bee2530 | ||
|
|
403b7c6177 | ||
|
|
3cfa2c12db | ||
|
|
fdf52dd45c | ||
|
|
87cb290cbd | ||
|
|
39aae712cf | ||
|
|
dd957acc5b | ||
|
|
b6e65e8b3c | ||
|
|
5fa729804a | ||
|
|
299759c1ba | ||
|
|
0cf5d1c561 | ||
|
|
6a74859ca1 | ||
|
|
d2dc8416f6 | ||
|
|
bf9940b3b2 | ||
|
|
6c3abc6d01 | ||
|
|
45bad20884 | ||
|
|
a612211ce3 | ||
|
|
1108da9574 | ||
|
|
880c78f1d7 | ||
|
|
22171be811 | ||
|
|
5428200593 | ||
|
|
a39c19dfa5 | ||
|
|
9dc3574cc1 | ||
|
|
0f6d0f40ff | ||
|
|
e489013ae1 | ||
|
|
3969177094 | ||
|
|
f2d07b6b8e | ||
|
|
2b3f48a44c | ||
|
|
8b1c6ed1b3 | ||
|
|
6c9e8549ca | ||
|
|
4fc57f13ab | ||
|
|
d1c95055ca | ||
|
|
264f790728 | ||
|
|
60e7db2346 | ||
|
|
7374a963be | ||
|
|
64c13cd46b | ||
|
|
5bb9731a84 | ||
|
|
836a07a670 | ||
|
|
b78166f8df | ||
|
|
563e98d334 | ||
|
|
12a9097742 | ||
|
|
021ef24ea9 | ||
|
|
1e8019aff5 | ||
|
|
cd4524aba0 | ||
|
|
a2e638352b | ||
|
|
91f5cd23c2 | ||
|
|
7aa169af9e | ||
|
|
b22ecf33f1 | ||
|
|
31df45f316 | ||
|
|
5e20794cfe | ||
|
|
b768649a10 | ||
|
|
007efb3691 | ||
|
|
d38179bc79 | ||
|
|
05f18add0b | ||
|
|
b3425159e1 | ||
|
|
89d5428954 | ||
|
|
844c5a14e8 | ||
|
|
7d670ee7e1 | ||
|
|
b6acd8b685 | ||
|
|
b4cc173235 | ||
|
|
0664013253 | ||
|
|
365c008d70 | ||
|
|
ca6a767dc0 | ||
|
|
aadbb1ebf4 | ||
|
|
3875ae2319 | ||
|
|
5052887efb | ||
|
|
3e02e2c12c | ||
|
|
c6df872d4a | ||
|
|
e1f512181f | ||
|
|
fcfb12d0b3 | ||
|
|
7592031b36 | ||
|
|
77e924295b | ||
|
|
5a07677422 | ||
|
|
b6a9ef0919 | ||
|
|
b31aed4283 | ||
|
|
045f4f4f4a | ||
|
|
14d44553c5 | ||
|
|
f02dd1c6aa | ||
|
|
13f4755ac2 | ||
|
|
18bcbc4903 | ||
|
|
db9ec5ccae | ||
|
|
6b8e230f45 | ||
|
|
5abe8852c1 | ||
|
|
8b9cb9d034 | ||
|
|
804c3a9be0 | ||
|
|
648efa9391 | ||
|
|
83f0acfc03 | ||
|
|
88c70cd762 | ||
|
|
fed65b983a | ||
|
|
39c5f1705e | ||
|
|
45f71a94f8 | ||
|
|
18f5bb15b7 | ||
|
|
594733024a | ||
|
|
f666d108b4 | ||
|
|
228d804962 | ||
|
|
e9242cf546 |
524
.drone.yml
524
.drone.yml
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,9 @@ cert_key =
|
||||
# Unix socket path
|
||||
socket = /tmp/grafana.sock
|
||||
|
||||
# CDN Url
|
||||
cdn_url =
|
||||
|
||||
#################################### Database ############################
|
||||
[database]
|
||||
# You can configure the database connection by specifying type, host, name, user and password
|
||||
@@ -159,6 +162,9 @@ send_user_header = false
|
||||
# Change this option to false to disable reporting.
|
||||
reporting_enabled = true
|
||||
|
||||
# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs
|
||||
reporting_distributor = grafana-labs
|
||||
|
||||
# Set to false to disable all checks to https://grafana.com
|
||||
# for new versions (grafana itself and plugins), check is used
|
||||
# in some UI views to notify that grafana or plugin update exists
|
||||
@@ -893,3 +899,7 @@ use_browser_locale = false
|
||||
|
||||
# Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc.
|
||||
default_timezone = browser
|
||||
|
||||
[expressions]
|
||||
# Enable or disable the expressions functionality.
|
||||
enabled = true
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
# Unix socket path
|
||||
;socket =
|
||||
|
||||
# CDN Url
|
||||
;cdn_url =
|
||||
|
||||
#################################### Database ####################################
|
||||
[database]
|
||||
# You can configure the database connection by specifying type, host, name, user and password
|
||||
@@ -165,6 +168,9 @@
|
||||
# Change this option to false to disable reporting.
|
||||
;reporting_enabled = true
|
||||
|
||||
# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs
|
||||
;reporting_distributor = grafana-labs
|
||||
|
||||
# Set to false to disable all checks to https://grafana.net
|
||||
# for new versions (grafana itself and plugins), check is used
|
||||
# in some UI views to notify that grafana or plugin update exists
|
||||
@@ -883,3 +889,7 @@
|
||||
|
||||
# Default timezone for user preferences. Options are 'browser' for the browser local timezone or a timezone name from IANA Time Zone database, e.g. 'UTC' or 'Europe/Amsterdam' etc.
|
||||
;default_timezone = browser
|
||||
|
||||
[expressions]
|
||||
# Enable or disable the expressions functionality.
|
||||
;enabled = true
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 16,
|
||||
"h": 18,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 11,
|
||||
"options": {
|
||||
"content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n* `__user.email` = `${__user.email}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n* `Server:text` = `${Server:text}`\n\n",
|
||||
"content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n* `__user.email` = `${__user.email}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n* `Server:text` = `${Server:text}`\n* `Server:queryparam` = `${Server:queryparam}`\n\n",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "7.1.0",
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
},
|
||||
"id": 3,
|
||||
"libraryPanel": {
|
||||
"uid": "MAnX2ifMk"
|
||||
"uid": "MAnX2ifMk",
|
||||
"name": "React Table"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -154,7 +155,8 @@
|
||||
},
|
||||
"id": 2,
|
||||
"libraryPanel": {
|
||||
"uid": "g1sNpCaMz"
|
||||
"uid": "g1sNpCaMz",
|
||||
"name": "React Gauge"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
40
devenv/docker/blocks/influxdb2/docker-compose.yaml
Normal file
40
devenv/docker/blocks/influxdb2/docker-compose.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
influxdb:
|
||||
image: quay.io/influxdb/influxdb:v2.0.3
|
||||
container_name: influxdb2
|
||||
ports:
|
||||
- '8086:8086'
|
||||
environment:
|
||||
INFLUXDB_REPORTING_DISABLED: 'true'
|
||||
volumes:
|
||||
- ./docker/blocks/influxdb2/influxdb.conf:/etc/influxdb/influxdb.conf
|
||||
|
||||
# Use the influx cli to set up an influxdb instance.
|
||||
influxdb_cli:
|
||||
links:
|
||||
- influxdb
|
||||
image: quay.io/influxdb/influxdb:v2.0.3
|
||||
# Use these same configurations parameters in your telegraf configuration, mytelegraf.conf.
|
||||
entrypoint: influx setup --bucket mybucket -t mytoken -o myorg --username=grafana --password=grafana12345 --host=http://influxdb:8086 -f
|
||||
# Wait for the influxd service in the influxdb container has fully bootstrapped before trying to setup an influxdb instance with the influxdb_cli service.
|
||||
restart: on-failure:10
|
||||
depends_on:
|
||||
- influxdb
|
||||
|
||||
fake-influxdb-data:
|
||||
image: grafana/fake-data-gen
|
||||
links:
|
||||
- influxdb
|
||||
environment:
|
||||
FD_DATASOURCE: influxdb
|
||||
FD_PORT: 8086
|
||||
|
||||
telegraf:
|
||||
image: telegraf
|
||||
links:
|
||||
- influxdb
|
||||
depends_on:
|
||||
- influxdb_cli
|
||||
volumes:
|
||||
- ./docker/blocks/influxdb2/telegraf.conf:/etc/telegraf/telegraf.conf:ro
|
||||
- /var/log:/var/log
|
||||
- ../data/log:/var/log/grafana
|
||||
93
devenv/docker/blocks/influxdb2/influxdb.conf
Normal file
93
devenv/docker/blocks/influxdb2/influxdb.conf
Normal file
@@ -0,0 +1,93 @@
|
||||
reporting-disabled = false
|
||||
|
||||
[meta]
|
||||
# Where the metadata/raft database is stored
|
||||
dir = "/var/lib/influxdb/meta"
|
||||
|
||||
retention-autocreate = true
|
||||
|
||||
# If log messages are printed for the meta service
|
||||
logging-enabled = true
|
||||
pprof-enabled = false
|
||||
|
||||
# The default duration for leases.
|
||||
lease-duration = "1m0s"
|
||||
|
||||
[data]
|
||||
# Controls if this node holds time series data shards in the cluster
|
||||
enabled = true
|
||||
|
||||
dir = "/var/lib/influxdb/data"
|
||||
|
||||
# These are the WAL settings for the storage engine >= 0.9.3
|
||||
wal-dir = "/var/lib/influxdb/wal"
|
||||
wal-logging-enabled = true
|
||||
|
||||
|
||||
[coordinator]
|
||||
write-timeout = "10s"
|
||||
max-concurrent-queries = 0
|
||||
query-timeout = "0"
|
||||
log-queries-after = "0"
|
||||
max-select-point = 0
|
||||
max-select-series = 0
|
||||
max-select-buckets = 0
|
||||
|
||||
[retention]
|
||||
enabled = true
|
||||
check-interval = "30m"
|
||||
|
||||
[shard-precreation]
|
||||
enabled = true
|
||||
check-interval = "10m"
|
||||
advance-period = "30m"
|
||||
|
||||
[monitor]
|
||||
store-enabled = true # Whether to record statistics internally.
|
||||
store-database = "_internal" # The destination database for recorded statistics
|
||||
store-interval = "10s" # The interval at which to record statistics
|
||||
|
||||
[admin]
|
||||
enabled = true
|
||||
bind-address = ":8083"
|
||||
https-enabled = false
|
||||
https-certificate = "/etc/ssl/influxdb.pem"
|
||||
|
||||
[http]
|
||||
enabled = true
|
||||
bind-address = ":8086"
|
||||
auth-enabled = true
|
||||
log-enabled = true
|
||||
write-tracing = false
|
||||
pprof-enabled = false
|
||||
https-enabled = false
|
||||
https-certificate = "/etc/ssl/influxdb.pem"
|
||||
### Use a separate private key location.
|
||||
# https-private-key = ""
|
||||
max-row-limit = 10000
|
||||
realm = "InfluxDB"
|
||||
|
||||
unix-socket-enabled = false # enable http service over unix domain socket
|
||||
# bind-socket = "/var/run/influxdb.sock"
|
||||
flux-enabled = true
|
||||
|
||||
[subscriber]
|
||||
enabled = true
|
||||
|
||||
[[graphite]]
|
||||
enabled = false
|
||||
|
||||
[[collectd]]
|
||||
enabled = false
|
||||
|
||||
[[opentsdb]]
|
||||
enabled = false
|
||||
|
||||
[[udp]]
|
||||
enabled = false
|
||||
|
||||
[continuous_queries]
|
||||
log-enabled = true
|
||||
enabled = true
|
||||
# run-interval = "1s" # interval for how often continuous queries will be checked if they need to run
|
||||
|
||||
5268
devenv/docker/blocks/influxdb2/telegraf.conf
Normal file
5268
devenv/docker/blocks/influxdb2/telegraf.conf
Normal file
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,15 @@ Path to the certificate key file (if `protocol` is set to `https` or `h2`).
|
||||
|
||||
Path where the socket should be created when `protocol=socket`. Make sure that Grafana has appropriate permissions before you change this setting.
|
||||
|
||||
### cdn_url
|
||||
|
||||
> **Note**: Available in Grafana v7.4 and later versions.
|
||||
|
||||
Specify a full HTTP URL address to the root of your Grafana CDN assets. Grafana will add edition and version paths.
|
||||
|
||||
For example, given a cdn url like `https://cdn.myserver.com` grafana will try to load a javascript file from
|
||||
`http://cdn.myserver.com/grafana-oss/v7.4.0/public/build/app.<hash>.js`.
|
||||
|
||||
<hr />
|
||||
|
||||
## [database]
|
||||
@@ -1505,3 +1514,8 @@ Set this to `true` to have date formats automatically derived from your browser
|
||||
### default_timezone
|
||||
|
||||
Used as the default time zone for user preferences. Can be either `browser` for the browser local time zone or a time zone name from the IANA Time Zone database, such as `UTC` or `Europe/Amsterdam`.
|
||||
|
||||
## [expressions]
|
||||
> **Note:** This feature is available in Grafana v7.4 and later versions.
|
||||
### enabled
|
||||
Set this to `false` to disable expressions and hide them in the Grafana UI. Default is `true`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title = "Activate an Enterprise license"
|
||||
description = "Activate an Enterprise license"
|
||||
keywords = ["grafana", "licensing", "enterprise"]
|
||||
weight = 7
|
||||
weight = 100
|
||||
+++
|
||||
|
||||
# Activate an Enterprise license
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title = "License Expiration"
|
||||
description = ""
|
||||
keywords = ["grafana", "licensing"]
|
||||
weight = 8
|
||||
weight = 120
|
||||
+++
|
||||
|
||||
# License expiration
|
||||
|
||||
49
docs/sources/enterprise/license-restrictions.md
Normal file
49
docs/sources/enterprise/license-restrictions.md
Normal file
@@ -0,0 +1,49 @@
|
||||
+++
|
||||
title = "License restrictions"
|
||||
description = "Grafana Enterprise license restrictions"
|
||||
keywords = ["grafana", "licensing", "enterprise"]
|
||||
weight = 110
|
||||
+++
|
||||
|
||||
# License restrictions
|
||||
|
||||
Enterprise licenses are limited by the number of active users, a license expiration date, and the URL of the Grafana instance.
|
||||
|
||||
## User limits
|
||||
|
||||
Grafana licenses allow for a certain number of active users per instance. An active user is any user that has signed in to Grafana within the past 30 days.
|
||||
|
||||
In the context of licensing, each user is classified as either a viewer or an editor:
|
||||
|
||||
- An editor is a user who has permission to edit and save a dashboard. Examples of editors are as follows:
|
||||
- Grafana server administrators.
|
||||
- Users who are assigned an organizational role of Editor or Admin.
|
||||
- Users that have been granted Admin or Edit permissions at the dashboard or folder level. Refer to [Dashboard and folder permissions](https://grafana.com/docs/grafana/latest/permissions/dashboard_folder_permissions/).
|
||||
- A viewer is a user with the Viewer role, which does not permit the user to save a dashboard.
|
||||
|
||||
Restrictions are applied separately for viewers and editors.
|
||||
|
||||
When the number of maximum active viewers or editors is reached, Grafana displays a warning banner.
|
||||
|
||||
## Expiration date
|
||||
|
||||
The license expiration date is the date when a license is no longer active. As the license expiration date approaches, Grafana Enterprise displays a banner.
|
||||
|
||||
## License URL
|
||||
|
||||
License URL is the root URL of your Grafana instance. The license will not work on an instance of Grafana with a different root URL.
|
||||
|
||||
## Download a dashboard and folder permissions report
|
||||
|
||||
This CSV report helps to identify users, teams, and roles that have been granted Admin or Edit permissions at the dashboard or folder level.
|
||||
|
||||
To download the report:
|
||||
1. Hover your cursor over the **Server Admin** (shield) icon in the side menu and then click **Licensing**.
|
||||
2. At the bottom of the page, click **Download report**.
|
||||
|
||||
## Update license restrictions
|
||||
|
||||
To increase the number of licensed users within Grafana, extend a license, or change your licensed URL, contact [Grafana support](https://grafana.com/profile/org#support) or your Grafana Labs account team. They will update your license, which you can activate from within Grafana.
|
||||
|
||||
For instructions on how to activate your license after it is updated, refer to
|
||||
[Activate an Enterprise license]({{< relref "./activate-license.md" >}})
|
||||
@@ -49,6 +49,6 @@ Value-specific variables are available under ``__value`` namespace:
|
||||
|
||||
When linking to another dashboard that uses template variables, select variable values for whoever clicks the link.
|
||||
|
||||
``var-myvar=${myvar}`` - where ``myvar`` is a name of the template variable that matches one in the current dashboard that you want to use.
|
||||
``${myvar:queryparams}`` - where ``myvar`` is a name of the template variable that matches one in the current dashboard that you want to use.
|
||||
|
||||
If you want to add all of the current dashboard's variables to the URL, then use ``__all_variables``.
|
||||
|
||||
95
docs/sources/panels/visualizations/node-graph.md
Normal file
95
docs/sources/panels/visualizations/node-graph.md
Normal file
@@ -0,0 +1,95 @@
|
||||
+++
|
||||
title = "Node graph"
|
||||
keywords = ["grafana", "dashboard", "documentation", "panels", "node graph", "directed graph"]
|
||||
weight = 850
|
||||
+++
|
||||
|
||||
# Node graph panel
|
||||
|
||||
> **Note:** This panel is currently in beta. Expect changes in future releases.
|
||||
|
||||
The _Node graph_ can visualize directed graphs or networks. It uses directed force layout to effectively position the nodes so it can help with displaying complex infrastructure maps, hierarchies or execution diagrams.
|
||||
|
||||

|
||||
|
||||
## Data requirements
|
||||
|
||||
The Node graph panel requires specific shape of the data to be able to display it's nodes and edges. This means not every data source or query can be visualized in this panel. If you want to use this as a data source developer see the section about data API.
|
||||
|
||||
The Node graph visualization consists of _nodes_ and _edges_.
|
||||
|
||||
- A _node_ is displayed as a circle. A node might represent an application, a service, or anything else that is relevant from an application perspective.
|
||||
- An _edge_ is displayed as a line that connects two nodes. The connection might be a request, an execution, or some other relationship between the two nodes.
|
||||
|
||||
Both nodes and edges can have associated metadata or statistics. The data source defines what information and values is shown, so different data sources can show different type of values or not show some values.
|
||||
|
||||
### Nodes
|
||||
|
||||
> **Note:** At this moment node graph can show only 1,500 nodes. If this limit is crossed a warning will be visible in upper right corner.
|
||||
|
||||
Usually, nodes show two statistical values inside the node and two identifiers just below the node, usually name and type. Nodes can also show another set of values as a color circle around the node, with sections of different color represents different values that should add up to 1.
|
||||
|
||||
For example you can have percentage of errors represented by red portion of the circle. Additional details can be displayed in a context menu when which is displayed when you click on the node. There also can be additional links in the context menu that can target either other parts of Grafana or any external link.
|
||||
|
||||

|
||||
|
||||
### Edges
|
||||
|
||||
Edges can also show statistics when you hover over the edge. Similar to nodes, you can open a context menu with additional details and links by clicking on the edge.
|
||||
|
||||
The first data source supporting this visualization is X-Ray data source for it's Service map feature. For more information, refer to the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource).
|
||||
|
||||
## Navigating the node graph
|
||||
|
||||
You can pan and zoom in or out the node graph.
|
||||
|
||||
### Pan
|
||||
|
||||
You can pan the view by clicking outside of any node or edge and dragging your mouse.
|
||||
|
||||
### Zoom in or out
|
||||
|
||||
Use the buttons on the upper left corner or use the mouse wheel, touch pad scroll, together with either Ctrl or Cmd key to zoom in or out.
|
||||
|
||||
## Data API
|
||||
|
||||
This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it.
|
||||
|
||||
Data source needs to return two data frames, one for nodes and one for edges and you also have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames.
|
||||
|
||||
### Node parameters
|
||||
|
||||
Required fields:
|
||||
|
||||
| Field name | Type | Description |
|
||||
|------------|---------|-------------|
|
||||
| id | string | Unique identifier of the node. This ID is referenced by edge in it's source and target field. |
|
||||
|
||||
Optional fields:
|
||||
|
||||
| Field name | Type | Description |
|
||||
|------------|---------|-------------|
|
||||
| title | string | Name of the node visible in just under the node. |
|
||||
| subTitle | string | Additional, name, type or other identifier that will be shown right under the title. |
|
||||
| mainStat | string/number | First stat shown inside the node itself. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown. |
|
||||
| secondaryStat | string/number | Same as mainStat but shown right under it inside the node. |
|
||||
| arc__* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||
| detail__* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||
|
||||
### Edge parameters
|
||||
|
||||
Required fields:
|
||||
|
||||
| Field name | Type | Description |
|
||||
|------------|---------|-------------|
|
||||
| id | string | Unique identifier of the edge. |
|
||||
| source | string | Id of the source node. |
|
||||
| target | string | Id of the target. |
|
||||
|
||||
Optional fields:
|
||||
|
||||
| Field name | Type | Description |
|
||||
|------------|---------|-------------|
|
||||
| mainStat | string/number | First stat shown in the overlay when hovering over the edge. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown |
|
||||
| secondaryStat | string/number | Same as mainStat but shown right under it. |
|
||||
| detail__* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |
|
||||
@@ -149,3 +149,13 @@ servers = ["test1", "test2"]
|
||||
String to interpolate: '${servers:text}'
|
||||
Interpolation result: "test1 + test2"
|
||||
```
|
||||
|
||||
## Query parameters
|
||||
|
||||
Formats single- and multi-valued variables into their query parameter representation. Example: `var-foo=value1&var-foo=value2`
|
||||
|
||||
```bash
|
||||
servers = ["test1", "test2"]
|
||||
String to interpolate: '${servers:queryparam}'
|
||||
Interpolation result: "var-servers=test1&var-servers=test2"
|
||||
```
|
||||
|
||||
@@ -32,6 +32,8 @@ All the information and stats shown in the Node graph beta are driven by the dat
|
||||
|
||||
For more details about how to use the X-Ray service map feature, see the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource).
|
||||
|
||||
For more information, refer to [Node graph panel]({{< relref "../panels/visualizations/node-graph.md" >}}).
|
||||
|
||||
### New transformations
|
||||
|
||||
The following transformations were added in Grafana 7.4.
|
||||
@@ -77,11 +79,13 @@ The main use case is for [multi-dimensional](https://grafana.com/docs/grafana/la
|
||||
|
||||
> **Note:** Queries built with this feature may break with minor version upgrades until Grafana 8 is released.
|
||||
|
||||
### Variable support in alert notifications
|
||||
### Alert notification query label interpolation
|
||||
|
||||
You can now provide detailed information to alert notification recipients by injecting alert query data into an alert notification. Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields. The alert label data is injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated.
|
||||
You can now provide detailed information to alert notification recipients by injecting alert label data as template variables into an alert notification. Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields using the `${Label}` syntax. The alert label data is automatically injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated.
|
||||
|
||||

|
||||

|
||||
|
||||
For more information, refer to the [alert notification docs]({{< relref "../alerting/notifications.md#notification-templating" >}}).
|
||||
|
||||
### Content security policy support
|
||||
|
||||
|
||||
@@ -35,11 +35,12 @@ e2e.scenario({
|
||||
`Server:sqlstring = 'A''A"A','BB\\\B','CCC'`,
|
||||
`Server:date = null`,
|
||||
`Server:text = All`,
|
||||
`Server:queryparam = var-Server=All`,
|
||||
];
|
||||
|
||||
e2e()
|
||||
.get('.markdown-html li')
|
||||
.should('have.length', 23)
|
||||
.should('have.length', 24)
|
||||
.each((element) => {
|
||||
items.push(element.text());
|
||||
})
|
||||
|
||||
@@ -75,36 +75,36 @@ const expectDrawerTabsAndContent = () => {
|
||||
expect(li.text()).equals('Data');
|
||||
});
|
||||
e2e.components.PanelInspector.Data.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Json.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Query.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
// other tabs should also be visible, click on each to see if we get any console errors
|
||||
e2e.components.Tab.title('Stats').should('be.visible').click();
|
||||
e2e.components.PanelInspector.Stats.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Json.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Query.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
e2e.components.Tab.title('JSON').should('be.visible').click();
|
||||
e2e.components.PanelInspector.Json.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Query.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
e2e.components.Tab.title('Query').should('be.visible').click();
|
||||
|
||||
e2e.components.PanelInspector.Query.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Json.content().should('not.be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
});
|
||||
};
|
||||
|
||||
const expectDrawerClose = () => {
|
||||
// close using close button
|
||||
e2e.components.Drawer.General.close().click();
|
||||
e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.be.visible');
|
||||
e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.exist');
|
||||
};
|
||||
|
||||
const expectDrawerExpandAndContract = (viewPortWidth: number) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ e2e.scenario({
|
||||
});
|
||||
e2e.components.QueryTab.content().should('be.visible');
|
||||
e2e.components.TransformTab.content().should('not.exist');
|
||||
e2e.components.AlertTab.content().should('not.be.visible');
|
||||
e2e.components.AlertTab.content().should('not.exist');
|
||||
|
||||
// Bottom pane tabs
|
||||
// Can change to Transform tab
|
||||
@@ -36,8 +36,8 @@ e2e.scenario({
|
||||
expect(li.text()).equals('Transform0'); // there's no transform so therefore Transform + 0
|
||||
});
|
||||
e2e.components.Transforms.card('Merge').scrollIntoView().should('be.visible');
|
||||
e2e.components.QueryTab.content().should('not.be.visible');
|
||||
e2e.components.AlertTab.content().should('not.be.visible');
|
||||
e2e.components.QueryTab.content().should('not.exist');
|
||||
e2e.components.AlertTab.content().should('not.exist');
|
||||
|
||||
// Can change to Alerts tab (graph panel is the default vis so the alerts tab should be rendered)
|
||||
e2e.components.Tab.title('Alert').should('be.visible').click();
|
||||
@@ -45,7 +45,7 @@ e2e.scenario({
|
||||
expect(li.text()).equals('Alert0'); // there's no alert so therefore Alert + 0
|
||||
});
|
||||
e2e.components.AlertTab.content().should('be.visible');
|
||||
e2e.components.QueryTab.content().should('not.be.visible');
|
||||
e2e.components.QueryTab.content().should('not.exist');
|
||||
e2e.components.TransformTab.content().should('not.exist');
|
||||
|
||||
e2e.components.Tab.title('Query').should('be.visible').click();
|
||||
@@ -56,18 +56,18 @@ e2e.scenario({
|
||||
|
||||
// Can toggle on/off sidebar
|
||||
e2e.components.PanelEditor.OptionsPane.close().should('be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.open().should('not.be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.open().should('not.exist');
|
||||
|
||||
// close options pane
|
||||
e2e.components.PanelEditor.OptionsPane.close().click();
|
||||
e2e.components.PanelEditor.OptionsPane.open().should('be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.close().should('not.be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.content().should('not.be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.close().should('not.exist');
|
||||
e2e.components.PanelEditor.OptionsPane.content().should('not.exist');
|
||||
|
||||
// open options pane
|
||||
e2e.components.PanelEditor.OptionsPane.open().click();
|
||||
e2e.components.PanelEditor.OptionsPane.close().should('be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.open().should('not.be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.open().should('not.exist');
|
||||
e2e.components.PanelEditor.OptionsPane.content().should('be.visible');
|
||||
|
||||
// Can change visualisation type
|
||||
@@ -86,7 +86,7 @@ e2e.scenario({
|
||||
});
|
||||
|
||||
// Data pane should not be rendered
|
||||
e2e.components.PanelEditor.DataPane.content().should('not.be.visible');
|
||||
e2e.components.PanelEditor.DataPane.content().should('not.exist');
|
||||
|
||||
// Change to Table panel
|
||||
e2e.components.PluginVisualization.item('Table').scrollIntoView().should('be.visible').click();
|
||||
@@ -103,12 +103,12 @@ e2e.scenario({
|
||||
e2e.components.PanelEditor.OptionsPane.tab('Field').click();
|
||||
|
||||
e2e.components.FieldConfigEditor.content().should('be.visible');
|
||||
e2e.components.OverridesConfigEditor.content().should('not.be.visible');
|
||||
e2e.components.OverridesConfigEditor.content().should('not.exist');
|
||||
|
||||
e2e.components.PanelEditor.OptionsPane.tab('Field').should('be.visible');
|
||||
e2e.components.PanelEditor.OptionsPane.tab('Overrides').should('be.visible').click();
|
||||
|
||||
e2e.components.OverridesConfigEditor.content().should('be.visible');
|
||||
e2e.components.FieldConfigEditor.content().should('not.be.visible');
|
||||
e2e.components.FieldConfigEditor.content().should('not.exist');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ e2e.scenario({
|
||||
|
||||
e2e.components.Select.option().should('be.visible').first().click();
|
||||
|
||||
e2e.components.Select.input().should('be.visible').should('have.focus');
|
||||
e2e.components.Select.input().should('exist').should('have.focus');
|
||||
});
|
||||
|
||||
e2e.pages.Dashboard.Settings.General.title().click();
|
||||
@@ -25,7 +25,7 @@ e2e.scenario({
|
||||
e2e.components.FolderPicker.container()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.components.Select.input().should('be.visible').should('not.have.focus');
|
||||
e2e.components.Select.input().should('exist').should('not.have.focus');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,27 +95,34 @@ describe('Variables - Add variable', () => {
|
||||
.type('*')
|
||||
.blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput()
|
||||
.should('be.visible')
|
||||
.type('/.*C.*/')
|
||||
.blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist');
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().should('be.visible').click();
|
||||
|
||||
e2e().wait(500);
|
||||
e2e().wait(1500);
|
||||
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible');
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').eq(1).should('be.visible').click();
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()
|
||||
.should('be.visible')
|
||||
e2e.pages.Dashboard.SubMenu.submenuItem()
|
||||
.should('have.length', 4)
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
e2e().get('.variable-option').should('have.length', 3);
|
||||
});
|
||||
e2e().get('.variable-link-wrapper').should('be.visible').click();
|
||||
e2e().wait(500);
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e().get('.variable-option').should('have.length', 1);
|
||||
});
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('adding a multi value query variable', () => {
|
||||
@@ -142,6 +149,11 @@ describe('Variables - Add variable', () => {
|
||||
.type('*')
|
||||
.blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput()
|
||||
.should('be.visible')
|
||||
.type('/.*C.*/')
|
||||
.blur();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch()
|
||||
.click({ force: true })
|
||||
.should('be.checked');
|
||||
@@ -164,18 +176,20 @@ describe('Variables - Add variable', () => {
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible');
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').eq(1).should('be.visible').click();
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()
|
||||
.should('be.visible')
|
||||
e2e.pages.Dashboard.SubMenu.submenuItem()
|
||||
.should('have.length', 4)
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
e2e().get('.variable-option').should('have.length', 4);
|
||||
});
|
||||
e2e().get('.variable-link-wrapper').should('be.visible').click();
|
||||
e2e().wait(500);
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e().get('.variable-option').should('have.length', 2);
|
||||
});
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible');
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1
go.mod
1
go.mod
@@ -34,6 +34,7 @@ require (
|
||||
github.com/getsentry/sentry-go v0.9.0
|
||||
github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b
|
||||
github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/go-stack/stack v1.8.0
|
||||
github.com/gobwas/glob v0.2.3
|
||||
|
||||
2
go.sum
2
go.sum
@@ -485,6 +485,8 @@ github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7
|
||||
github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=
|
||||
github.com/go-redis/redis/v8 v8.0.0-beta.10.0.20200905143926-df7fe4e2ce72/go.mod h1:CJP1ZIHwhosNYwIdaHPZK9vHsM3+roNBaZ7U9Of1DXc=
|
||||
github.com/go-redis/redis/v8 v8.2.3/go.mod h1:ysgGY09J/QeDYbu3HikWEIPCwaeOkuNoTgKayTEaEOw=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "7.4.0-pre.0"
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "7.4.5"
|
||||
}
|
||||
|
||||
14
package.json
14
package.json
@@ -3,7 +3,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "7.4.0-pre",
|
||||
"version": "7.4.5",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
|
||||
@@ -46,7 +46,7 @@
|
||||
"ci:test-frontend": "yarn run prettier:check && yarn run typecheck && yarn run lint && yarn run test:ci && yarn grafana-toolkit node-version-check && ./scripts/ci-check-strict.sh"
|
||||
},
|
||||
"grafana": {
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v7-3/",
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v7-4/",
|
||||
"releaseNotesUrl": "https://grafana.com/docs/grafana/latest/release-notes/"
|
||||
},
|
||||
"husky": {
|
||||
@@ -89,6 +89,7 @@
|
||||
"@types/d3": "5.7.2",
|
||||
"@types/d3-force": "^2.1.0",
|
||||
"@types/d3-scale-chromatic": "1.3.1",
|
||||
"@types/debounce-promise": "3.1.3",
|
||||
"@types/enzyme": "3.10.5",
|
||||
"@types/enzyme-adapter-react-16": "1.0.6",
|
||||
"@types/file-saver": "2.0.1",
|
||||
@@ -168,7 +169,7 @@
|
||||
"monaco-editor-webpack-plugin": "1.9.0",
|
||||
"mutationobserver-shim": "0.3.3",
|
||||
"ngtemplate-loader": "2.0.1",
|
||||
"optimize-css-assets-webpack-plugin": "5.0.3",
|
||||
"optimize-css-assets-webpack-plugin": "5.0.4",
|
||||
"postcss-browser-reporter": "0.6.0",
|
||||
"postcss-loader": "3.0.0",
|
||||
"postcss-reporter": "6.0.1",
|
||||
@@ -184,7 +185,7 @@
|
||||
"sass-loader": "8.0.2",
|
||||
"sinon": "8.1.1",
|
||||
"style-loader": "1.1.3",
|
||||
"terser-webpack-plugin": "2.3.5",
|
||||
"terser-webpack-plugin": "2.3.7",
|
||||
"ts-jest": "26.4.4",
|
||||
"ts-node": "9.0.0",
|
||||
"tslib": "2.0.3",
|
||||
@@ -193,14 +194,14 @@
|
||||
"webpack-bundle-analyzer": "3.6.0",
|
||||
"webpack-cleanup-plugin": "0.5.1",
|
||||
"webpack-cli": "3.3.10",
|
||||
"webpack-dev-server": "3.10.3",
|
||||
"webpack-dev-server": "3.11.1",
|
||||
"webpack-merge": "4.2.2",
|
||||
"zone.js": "0.7.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@popperjs/core": "2.5.4",
|
||||
"@reduxjs/toolkit": "1.3.4",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"@sentry/browser": "5.25.0",
|
||||
"@sentry/types": "5.24.2",
|
||||
"@sentry/utils": "5.24.2",
|
||||
@@ -234,6 +235,7 @@
|
||||
"d3-force": "^2.1.1",
|
||||
"d3-scale-chromatic": "1.5.0",
|
||||
"dangerously-set-html-content": "1.0.6",
|
||||
"debounce-promise": "3.1.2",
|
||||
"emotion": "10.0.27",
|
||||
"eventemitter3": "4.0.0",
|
||||
"fast-text-encoding": "^1.0.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -144,33 +144,6 @@ describe('Format value', () => {
|
||||
expect(result.text).toEqual('10.0');
|
||||
});
|
||||
|
||||
it('should set auto decimals, 1 significant', () => {
|
||||
const value = 3.23;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null });
|
||||
expect(instance(value).text).toEqual('3.23');
|
||||
});
|
||||
|
||||
it('should set auto decimals, 2 significant', () => {
|
||||
const value = 0.0245;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null });
|
||||
|
||||
expect(instance(value).text).toEqual('0.0245');
|
||||
});
|
||||
|
||||
it('should set auto decimals correctly for value 0.333333333333', () => {
|
||||
const value = 1 / 3;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null });
|
||||
expect(instance(value).text).toEqual('0.333');
|
||||
});
|
||||
|
||||
it('should use override decimals', () => {
|
||||
const value = 100030303;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: 2, unit: 'bytes' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('95.40');
|
||||
expect(disp.suffix).toEqual(' MiB');
|
||||
});
|
||||
|
||||
it('should return mapped value if there are matching value mappings', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
@@ -211,7 +184,7 @@ describe('Format value', () => {
|
||||
const value = 1000;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('1.0');
|
||||
expect(disp.text).toEqual('1');
|
||||
expect(disp.suffix).toEqual(' K');
|
||||
});
|
||||
|
||||
@@ -219,7 +192,7 @@ describe('Format value', () => {
|
||||
const value = 1200;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('1.2');
|
||||
expect(disp.text).toEqual('1.20');
|
||||
expect(disp.suffix).toEqual(' K');
|
||||
});
|
||||
|
||||
@@ -235,7 +208,7 @@ describe('Format value', () => {
|
||||
const value = 1000000;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('1.0');
|
||||
expect(disp.text).toEqual('1');
|
||||
expect(disp.suffix).toEqual(' Mil');
|
||||
});
|
||||
|
||||
@@ -243,9 +216,17 @@ describe('Format value', () => {
|
||||
const value = 1500000;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('1.5');
|
||||
expect(disp.text).toEqual('1.50');
|
||||
expect(disp.suffix).toEqual(' Mil');
|
||||
});
|
||||
|
||||
it('with value 128000000 and unit bytes', () => {
|
||||
const value = 1280000125;
|
||||
const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'bytes' });
|
||||
const disp = instance(value);
|
||||
expect(disp.text).toEqual('1.19');
|
||||
expect(disp.suffix).toEqual(' GiB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date display options', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import _ from 'lodash';
|
||||
// Types
|
||||
import { Field, FieldType } from '../types/dataFrame';
|
||||
import { GrafanaTheme } from '../types/theme';
|
||||
import { DecimalCount, DecimalInfo, DisplayProcessor, DisplayValue } from '../types/displayValue';
|
||||
import { DisplayProcessor, DisplayValue } from '../types/displayValue';
|
||||
import { getValueFormat } from '../valueFormats/valueFormats';
|
||||
import { getMappedValue } from '../utils/valueMappings';
|
||||
import { dateTime } from '../datetime';
|
||||
@@ -27,9 +27,11 @@ interface DisplayProcessorOptions {
|
||||
// Reasonable units for time
|
||||
const timeFormats: KeyValue<boolean> = {
|
||||
dateTimeAsIso: true,
|
||||
dateTimeAsIsoSmart: true,
|
||||
dateTimeAsIsoNoDateIfToday: true,
|
||||
dateTimeAsUS: true,
|
||||
dateTimeAsUSSmart: true,
|
||||
dateTimeAsUSNoDateIfToday: true,
|
||||
dateTimeAsLocal: true,
|
||||
dateTimeAsLocalNoDateIfToday: true,
|
||||
dateTimeFromNow: true,
|
||||
};
|
||||
|
||||
@@ -86,8 +88,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
|
||||
|
||||
if (!isNaN(numeric)) {
|
||||
if (shouldFormat && !_.isBoolean(value)) {
|
||||
const { decimals, scaledDecimals } = getDecimalsForValue(value, config.decimals);
|
||||
const v = formatFunc(numeric, decimals, scaledDecimals, options.timeZone);
|
||||
const v = formatFunc(numeric, config.decimals, null, options.timeZone);
|
||||
text = v.text;
|
||||
suffix = v.suffix;
|
||||
prefix = v.prefix;
|
||||
@@ -137,53 +138,6 @@ function toStringProcessor(value: any): DisplayValue {
|
||||
return { text: _.toString(value), numeric: toNumber(value) };
|
||||
}
|
||||
|
||||
function getSignificantDigitCount(n: number) {
|
||||
//remove decimal and make positive
|
||||
n = Math.abs(+String(n).replace('.', ''));
|
||||
if (n === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// kill the 0s at the end of n
|
||||
while (n !== 0 && n % 10 === 0) {
|
||||
n /= 10;
|
||||
}
|
||||
|
||||
// get number of digits
|
||||
return Math.floor(Math.log(n) / Math.LN10) + 1;
|
||||
}
|
||||
|
||||
export function getDecimalsForValue(value: number, decimalOverride?: DecimalCount): DecimalInfo {
|
||||
if (_.isNumber(decimalOverride)) {
|
||||
// It's important that scaledDecimals is null here
|
||||
return { decimals: decimalOverride, scaledDecimals: null };
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return { decimals: 0, scaledDecimals: 0 };
|
||||
}
|
||||
|
||||
const digits = getSignificantDigitCount(value);
|
||||
const log10 = Math.floor(Math.log(Math.abs(value)) / Math.LN10);
|
||||
let dec = -log10 + 1;
|
||||
const magn = Math.pow(10, -dec);
|
||||
const norm = value / magn; // norm is between 1.0 and 10.0
|
||||
|
||||
// special case for 2.5, requires an extra decimal
|
||||
if (norm > 2.25) {
|
||||
++dec;
|
||||
}
|
||||
|
||||
if (value % 1 === 0) {
|
||||
dec = 0;
|
||||
}
|
||||
|
||||
const decimals = Math.max(0, dec);
|
||||
const scaledDecimals = decimals - log10 + digits - 1;
|
||||
|
||||
return { decimals, scaledDecimals };
|
||||
}
|
||||
|
||||
export function getRawDisplayProcessor(): DisplayProcessor {
|
||||
return (value: any) => ({
|
||||
text: `${value}`,
|
||||
|
||||
@@ -38,10 +38,8 @@ export function getFieldDisplayName(field: Field, frame?: DataFrame, allFrames?:
|
||||
}
|
||||
|
||||
const displayName = calculateFieldDisplayName(field, frame, allFrames);
|
||||
field.state = {
|
||||
...field.state,
|
||||
displayName,
|
||||
};
|
||||
field.state = field.state || {};
|
||||
field.state.displayName = displayName;
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@ export { sortThresholds, getActiveThreshold } from './thresholds';
|
||||
export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides } from './fieldOverrides';
|
||||
export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
export { getFieldDisplayName, getFrameDisplayName } from './fieldState';
|
||||
export { getScaleCalculator } from './scale';
|
||||
export { getScaleCalculator, getFieldConfigWithMinMax } from './scale';
|
||||
|
||||
@@ -65,14 +65,20 @@ function getMinMaxAndDelta(field: Field): NumericRange {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getFieldConfigWithMinMax(field: Field, local?: boolean): FieldConfig {
|
||||
const { config } = field;
|
||||
let { min, max } = config;
|
||||
if (isNumber(min) && !isNumber(max)) {
|
||||
return config; // noop
|
||||
|
||||
if (isNumber(min) && isNumber(max)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (local || !field.state?.range) {
|
||||
return { ...config, ...getMinMaxAndDelta(field) };
|
||||
}
|
||||
|
||||
return { ...config, ...field.state.range };
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@ export {
|
||||
BasicValueMatcherOptions,
|
||||
RangeValueMatcherOptions,
|
||||
} from './transformations/matchers/valueMatchers/types';
|
||||
export { PanelPlugin, SetFieldConfigOptionsArgs } from './panel/PanelPlugin';
|
||||
export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin';
|
||||
export { createFieldConfigRegistry } from './panel/registryFactories';
|
||||
|
||||
@@ -16,7 +16,8 @@ import { deprecationWarning } from '../utils';
|
||||
import { FieldConfigOptionsRegistry } from '../field';
|
||||
import { createFieldConfigRegistry } from './registryFactories';
|
||||
|
||||
type StandardOptionConfig = {
|
||||
/** @beta */
|
||||
export type StandardOptionConfig = {
|
||||
defaultValue?: any;
|
||||
settings?: any;
|
||||
};
|
||||
@@ -130,6 +131,7 @@ export class PanelPlugin<
|
||||
set(result, editor.id, editor.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,10 @@ export class PanelPlugin<
|
||||
configDefaults.custom = {} as TFieldConfigOptions;
|
||||
|
||||
for (const option of this.fieldConfigRegistry.list()) {
|
||||
if (option.defaultValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
set(configDefaults, option.id, option.defaultValue);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ export {
|
||||
} from './standardTransformersRegistry';
|
||||
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
|
||||
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
|
||||
export { outerJoinDataFrames } from './transformers/seriesToColumns';
|
||||
export { outerJoinDataFrames } from './transformers/joinDataFrames';
|
||||
|
||||
@@ -21,6 +21,7 @@ const seriesBC = toDataFrame({
|
||||
{ name: 'B', type: FieldType.number, values: [2, 200] },
|
||||
{ name: 'C', type: FieldType.number, values: [3, 300] },
|
||||
{ name: 'D', type: FieldType.string, values: ['first', 'second'] },
|
||||
{ name: 'E', type: FieldType.boolean, values: [true, false] },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -48,6 +49,7 @@ describe('calculateField transformer w/ timeseries', () => {
|
||||
B: 2,
|
||||
C: 3,
|
||||
D: 'first',
|
||||
E: true,
|
||||
'The Total': 6,
|
||||
TheTime: 1000,
|
||||
},
|
||||
@@ -56,6 +58,7 @@ describe('calculateField transformer w/ timeseries', () => {
|
||||
B: 200,
|
||||
C: 300,
|
||||
D: 'second',
|
||||
E: false,
|
||||
'The Total': 600,
|
||||
TheTime: 2000,
|
||||
},
|
||||
@@ -129,7 +132,7 @@ describe('calculateField transformer w/ timeseries', () => {
|
||||
mode: CalculateFieldMode.BinaryOperation,
|
||||
binary: {
|
||||
left: 'B',
|
||||
operation: BinaryOperationID.Add,
|
||||
operator: BinaryOperationID.Add,
|
||||
right: 'C',
|
||||
},
|
||||
replaceFields: true,
|
||||
@@ -160,7 +163,7 @@ describe('calculateField transformer w/ timeseries', () => {
|
||||
mode: CalculateFieldMode.BinaryOperation,
|
||||
binary: {
|
||||
left: 'B',
|
||||
operation: BinaryOperationID.Add,
|
||||
operator: BinaryOperationID.Add,
|
||||
right: '2',
|
||||
},
|
||||
replaceFields: true,
|
||||
@@ -183,4 +186,37 @@ describe('calculateField transformer w/ timeseries', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('boolean field', async () => {
|
||||
const cfg = {
|
||||
id: DataTransformerID.calculateField,
|
||||
options: {
|
||||
mode: CalculateFieldMode.BinaryOperation,
|
||||
binary: {
|
||||
left: 'E',
|
||||
operator: BinaryOperationID.Multiply,
|
||||
right: '1',
|
||||
},
|
||||
replaceFields: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
const rows = new DataFrameView(filtered).toArray();
|
||||
expect(rows).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"E * 1": 1,
|
||||
"TheTime": 1000,
|
||||
},
|
||||
Object {
|
||||
"E * 1": 0,
|
||||
"TheTime": 2000,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getFieldMatcher } from '../matchers';
|
||||
import { FieldMatcherID } from '../matchers/ids';
|
||||
import { RowVector } from '../../vector/RowVector';
|
||||
import { ArrayVector, BinaryOperationVector, ConstantVector } from '../../vector';
|
||||
import { AsNumberVector } from '../../vector/AsNumberVector';
|
||||
import { getTimeField } from '../../dataframe/processDataFrame';
|
||||
import defaults from 'lodash/defaults';
|
||||
import { BinaryOperationID, binaryOperators } from '../../utils/binaryOperators';
|
||||
@@ -187,6 +188,9 @@ function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string, allFr
|
||||
|
||||
for (const f of frame.fields) {
|
||||
if (name === getFieldDisplayName(f, frame, allFrames)) {
|
||||
if (f.type === FieldType.boolean) {
|
||||
return new AsNumberVector(f.values);
|
||||
}
|
||||
return f.values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,52 +49,74 @@ describe('ensureColumns transformer', () => {
|
||||
|
||||
const frame = filtered[0];
|
||||
expect(frame.fields.length).toEqual(5);
|
||||
expect(filtered[0]).toEqual(
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'TheTime',
|
||||
type: 'time',
|
||||
config: {},
|
||||
values: [1000, 2000],
|
||||
labels: undefined,
|
||||
expect(filtered[0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"fields": Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "TheTime",
|
||||
"state": Object {
|
||||
"displayName": "TheTime",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1000,
|
||||
2000,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'A',
|
||||
type: 'number',
|
||||
config: {},
|
||||
values: [1, 100],
|
||||
labels: {},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "A",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
1,
|
||||
100,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
type: 'number',
|
||||
config: {},
|
||||
values: [2, 200],
|
||||
labels: {},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "B",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
2,
|
||||
200,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'C',
|
||||
type: 'number',
|
||||
config: {},
|
||||
values: [3, 300],
|
||||
labels: {},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "C",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
3,
|
||||
300,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'D',
|
||||
type: 'string',
|
||||
config: {},
|
||||
values: ['first', 'second'],
|
||||
labels: {},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "D",
|
||||
"state": Object {},
|
||||
"type": "string",
|
||||
"values": Array [
|
||||
"first",
|
||||
"second",
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
transformations: ['ensureColumns'],
|
||||
"length": 2,
|
||||
"meta": Object {
|
||||
"transformations": Array [
|
||||
"ensureColumns",
|
||||
],
|
||||
},
|
||||
name: undefined,
|
||||
refId: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { toDataFrame } from '../../dataframe/processDataFrame';
|
||||
import { FieldType } from '../../types/dataFrame';
|
||||
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
|
||||
import { ArrayVector } from '../../vector';
|
||||
import { calculateFieldTransformer } from './calculateField';
|
||||
import { isLikelyAscendingVector, outerJoinDataFrames } from './joinDataFrames';
|
||||
|
||||
describe('align frames', () => {
|
||||
beforeAll(() => {
|
||||
mockTransformationsRegistry([calculateFieldTransformer]);
|
||||
});
|
||||
|
||||
it('by first time field', () => {
|
||||
const series1 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
|
||||
{ name: 'A', type: FieldType.number, values: [1, 100] },
|
||||
],
|
||||
});
|
||||
|
||||
const series2 = toDataFrame({
|
||||
fields: [
|
||||
{ name: '_time', type: FieldType.time, values: [1000, 1500, 2000] },
|
||||
{ name: 'A', type: FieldType.number, values: [2, 20, 200] },
|
||||
{ name: 'B', type: FieldType.number, values: [3, 30, 300] },
|
||||
{ name: 'C', type: FieldType.string, values: ['first', 'second', 'third'] },
|
||||
],
|
||||
});
|
||||
|
||||
const out = outerJoinDataFrames({ frames: [series1, series2] })!;
|
||||
expect(
|
||||
out.fields.map((f) => ({
|
||||
name: f.name,
|
||||
values: f.values.toArray(),
|
||||
}))
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"name": "TheTime",
|
||||
"values": Array [
|
||||
1000,
|
||||
1500,
|
||||
2000,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A",
|
||||
"values": Array [
|
||||
1,
|
||||
undefined,
|
||||
100,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A",
|
||||
"values": Array [
|
||||
2,
|
||||
20,
|
||||
200,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "B",
|
||||
"values": Array [
|
||||
3,
|
||||
30,
|
||||
300,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "C",
|
||||
"values": Array [
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('unsorted input keep indexes', () => {
|
||||
//----------
|
||||
const series1 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000, 1500] },
|
||||
{ name: 'A1', type: FieldType.number, values: [1, 2, 15] },
|
||||
],
|
||||
});
|
||||
|
||||
const series3 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [2000, 1000] },
|
||||
{ name: 'A2', type: FieldType.number, values: [2, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
let out = outerJoinDataFrames({ frames: [series1, series3], keepOriginIndices: true })!;
|
||||
expect(
|
||||
out.fields.map((f) => ({
|
||||
name: f.name,
|
||||
values: f.values.toArray(),
|
||||
state: f.state,
|
||||
}))
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"name": "TheTime",
|
||||
"state": Object {
|
||||
"origin": Object {
|
||||
"fieldIndex": 0,
|
||||
"frameIndex": 0,
|
||||
},
|
||||
},
|
||||
"values": Array [
|
||||
1000,
|
||||
1500,
|
||||
2000,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A1",
|
||||
"state": Object {
|
||||
"origin": Object {
|
||||
"fieldIndex": 1,
|
||||
"frameIndex": 0,
|
||||
},
|
||||
},
|
||||
"values": Array [
|
||||
1,
|
||||
15,
|
||||
2,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A2",
|
||||
"state": Object {
|
||||
"origin": Object {
|
||||
"fieldIndex": 1,
|
||||
"frameIndex": 1,
|
||||
},
|
||||
},
|
||||
"values": Array [
|
||||
1,
|
||||
undefined,
|
||||
2,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
// Fast path still adds origin indecies
|
||||
out = outerJoinDataFrames({ frames: [series1], keepOriginIndices: true })!;
|
||||
expect(
|
||||
out.fields.map((f) => ({
|
||||
name: f.name,
|
||||
state: f.state,
|
||||
}))
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"name": "TheTime",
|
||||
"state": Object {
|
||||
"origin": Object {
|
||||
"fieldIndex": 0,
|
||||
"frameIndex": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"name": "A1",
|
||||
"state": Object {
|
||||
"origin": Object {
|
||||
"fieldIndex": 1,
|
||||
"frameIndex": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('sort single frame', () => {
|
||||
const series1 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'TheTime', type: FieldType.time, values: [6000, 2000, 1500] },
|
||||
{ name: 'A1', type: FieldType.number, values: [1, 22, 15] },
|
||||
],
|
||||
});
|
||||
|
||||
const out = outerJoinDataFrames({ frames: [series1], enforceSort: true, keepOriginIndices: true })!;
|
||||
expect(
|
||||
out.fields.map((f) => ({
|
||||
name: f.name,
|
||||
values: f.values.toArray(),
|
||||
}))
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"name": "TheTime",
|
||||
"values": Array [
|
||||
1500,
|
||||
2000,
|
||||
6000,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A1",
|
||||
"values": Array [
|
||||
15,
|
||||
22,
|
||||
1,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('supports duplicate times', () => {
|
||||
//----------
|
||||
// NOTE!!!
|
||||
// * ideally we would *keep* dupicate fields
|
||||
//----------
|
||||
const series1 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
|
||||
{ name: 'A', type: FieldType.number, values: [1, 100] },
|
||||
],
|
||||
});
|
||||
|
||||
const series3 = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1000, 1000, 1000] },
|
||||
{ name: 'A', type: FieldType.number, values: [2, 20, 200] },
|
||||
],
|
||||
});
|
||||
|
||||
const out = outerJoinDataFrames({ frames: [series1, series3] })!;
|
||||
expect(
|
||||
out.fields.map((f) => ({
|
||||
name: f.name,
|
||||
values: f.values.toArray(),
|
||||
}))
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"name": "TheTime",
|
||||
"values": Array [
|
||||
1000,
|
||||
2000,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A",
|
||||
"values": Array [
|
||||
1,
|
||||
100,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "A",
|
||||
"values": Array [
|
||||
200,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
describe('check ascending data', () => {
|
||||
it('simple ascending', () => {
|
||||
const v = new ArrayVector([1, 2, 3, 4, 5]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
});
|
||||
it('simple ascending with null', () => {
|
||||
const v = new ArrayVector([null, 2, 3, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
});
|
||||
it('single value', () => {
|
||||
const v = new ArrayVector([null, null, null, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('middle values', () => {
|
||||
const v = new ArrayVector([null, null, 5, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('decending', () => {
|
||||
expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { DataFrame, Field, FieldMatcher, FieldType, Vector } from '../../types';
|
||||
import { ArrayVector } from '../../vector';
|
||||
import { fieldMatchers } from '../matchers';
|
||||
import { FieldMatcherID } from '../matchers/ids';
|
||||
import { getTimeField, sortDataFrame } from '../../dataframe';
|
||||
|
||||
export function pickBestJoinField(data: DataFrame[]): FieldMatcher {
|
||||
const { timeField } = getTimeField(data[0]);
|
||||
if (timeField) {
|
||||
return fieldMatchers.get(FieldMatcherID.firstTimeField).get({});
|
||||
}
|
||||
let common: string[] = [];
|
||||
for (const f of data[0].fields) {
|
||||
if (f.type === FieldType.number) {
|
||||
common.push(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const names: string[] = [];
|
||||
for (const f of data[0].fields) {
|
||||
if (f.type === FieldType.number) {
|
||||
names.push(f.name);
|
||||
}
|
||||
}
|
||||
common = common.filter((v) => !names.includes(v));
|
||||
}
|
||||
|
||||
return fieldMatchers.get(FieldMatcherID.byName).get(common[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface JoinOptions {
|
||||
/**
|
||||
* The input fields
|
||||
*/
|
||||
frames: DataFrame[];
|
||||
|
||||
/**
|
||||
* The field to join -- frames that do not have this field will be droppped
|
||||
*/
|
||||
joinBy?: FieldMatcher;
|
||||
|
||||
/**
|
||||
* Optionally filter the non-join fields
|
||||
*/
|
||||
keep?: FieldMatcher;
|
||||
|
||||
/**
|
||||
* When the result is a single frame, this will to a quick check to see if the values are sorted,
|
||||
* and sort if necessary. If the first/last values are in order the whole vector is assumed to be
|
||||
* sorted
|
||||
*/
|
||||
enforceSort?: boolean;
|
||||
|
||||
/**
|
||||
* @internal -- used when we need to keep a reference to the original frame/field index
|
||||
*/
|
||||
keepOriginIndices?: boolean;
|
||||
}
|
||||
|
||||
function getJoinMatcher(options: JoinOptions): FieldMatcher {
|
||||
return options.joinBy ?? pickBestJoinField(options.frames);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will return a single frame joined by the first matching field. When a join field is not specified,
|
||||
* the default will use the first time field
|
||||
*/
|
||||
export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined {
|
||||
if (!options.frames?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (options.frames.length === 1) {
|
||||
let frame = options.frames[0];
|
||||
if (options.keepOriginIndices) {
|
||||
frame = {
|
||||
...frame,
|
||||
fields: frame.fields.map((f, fieldIndex) => {
|
||||
const copy = { ...f };
|
||||
const origin = {
|
||||
frameIndex: 0,
|
||||
fieldIndex,
|
||||
};
|
||||
if (copy.state) {
|
||||
copy.state.origin = origin;
|
||||
} else {
|
||||
copy.state = { origin };
|
||||
}
|
||||
return copy;
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (options.enforceSort) {
|
||||
const joinFieldMatcher = getJoinMatcher(options);
|
||||
const joinIndex = frame.fields.findIndex((f) => joinFieldMatcher(f, frame, options.frames));
|
||||
if (joinIndex >= 0) {
|
||||
if (!isLikelyAscendingVector(frame.fields[joinIndex].values)) {
|
||||
return sortDataFrame(frame, joinIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
const nullModes: JoinNullMode[][] = [];
|
||||
const allData: AlignedData[] = [];
|
||||
const originalFields: Field[] = [];
|
||||
const joinFieldMatcher = getJoinMatcher(options);
|
||||
|
||||
for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) {
|
||||
const frame = options.frames[frameIndex];
|
||||
|
||||
if (!frame || !frame.fields?.length) {
|
||||
continue; // skip the frame
|
||||
}
|
||||
|
||||
const nullModesFrame: JoinNullMode[] = [NULL_REMOVE];
|
||||
let join: Field | undefined = undefined;
|
||||
let fields: Field[] = [];
|
||||
|
||||
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
|
||||
const field = frame.fields[fieldIndex];
|
||||
field.state = field.state || {};
|
||||
|
||||
if (!join && joinFieldMatcher(field, frame, options.frames)) {
|
||||
join = field;
|
||||
} else {
|
||||
if (options.keep && !options.keep(field, frame, options.frames)) {
|
||||
continue; // skip field
|
||||
}
|
||||
|
||||
// Support the standard graph span nulls field config
|
||||
nullModesFrame.push(field.config.custom?.spanNulls ? NULL_REMOVE : NULL_EXPAND);
|
||||
|
||||
let labels = field.labels ?? {};
|
||||
if (frame.name) {
|
||||
labels = { ...labels, name: frame.name };
|
||||
}
|
||||
|
||||
fields.push({
|
||||
...field,
|
||||
labels, // add the name label from frame
|
||||
});
|
||||
}
|
||||
|
||||
if (options.keepOriginIndices) {
|
||||
field.state.origin = {
|
||||
frameIndex,
|
||||
fieldIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!join) {
|
||||
continue; // skip the frame
|
||||
}
|
||||
|
||||
if (originalFields.length === 0) {
|
||||
originalFields.push(join); // first join field
|
||||
}
|
||||
|
||||
nullModes.push(nullModesFrame);
|
||||
const a: AlignedData = [join.values.toArray()]; //
|
||||
|
||||
for (const field of fields) {
|
||||
a.push(field.values.toArray());
|
||||
originalFields.push(field);
|
||||
// clear field displayName state
|
||||
delete field.state?.displayName;
|
||||
}
|
||||
|
||||
allData.push(a);
|
||||
}
|
||||
|
||||
const joined = join(allData, nullModes);
|
||||
return {
|
||||
// ...options.data[0], // keep name, meta?
|
||||
length: joined[0].length,
|
||||
fields: originalFields.map((f, index) => ({
|
||||
...f,
|
||||
values: new ArrayVector(joined[index]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------------
|
||||
// Below here is copied from uplot (MIT License)
|
||||
// https://github.com/leeoniya/uPlot/blob/master/src/utils.js#L325
|
||||
// This avoids needing to import uplot into the data package
|
||||
//--------------------------------------------------------------------------------
|
||||
|
||||
// Copied from uplot
|
||||
type AlignedData = [number[], ...Array<Array<number | null>>];
|
||||
|
||||
// nullModes
|
||||
const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true)
|
||||
const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default)
|
||||
const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts
|
||||
|
||||
type JoinNullMode = number; // NULL_IGNORE | NULL_RETAIN | NULL_EXPAND;
|
||||
|
||||
// sets undefined values to nulls when adjacent to existing nulls (minesweeper)
|
||||
function nullExpand(yVals: Array<number | null>, nullIdxs: number[], alignedLen: number) {
|
||||
for (let i = 0, xi, lastNullIdx = -1; i < nullIdxs.length; i++) {
|
||||
let nullIdx = nullIdxs[i];
|
||||
|
||||
if (nullIdx > lastNullIdx) {
|
||||
xi = nullIdx - 1;
|
||||
while (xi >= 0 && yVals[xi] == null) {
|
||||
yVals[xi--] = null;
|
||||
}
|
||||
|
||||
xi = nullIdx + 1;
|
||||
while (xi < alignedLen && yVals[xi] == null) {
|
||||
yVals[(lastNullIdx = xi++)] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nullModes is a tables-matched array indicating how to treat nulls in each series
|
||||
function join(tables: AlignedData[], nullModes: number[][]) {
|
||||
const xVals = new Set<number>();
|
||||
|
||||
for (let ti = 0; ti < tables.length; ti++) {
|
||||
let t = tables[ti];
|
||||
let xs = t[0];
|
||||
let len = xs.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
xVals.add(xs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let data = [Array.from(xVals).sort((a, b) => a - b)];
|
||||
|
||||
let alignedLen = data[0].length;
|
||||
|
||||
let xIdxs = new Map();
|
||||
|
||||
for (let i = 0; i < alignedLen; i++) {
|
||||
xIdxs.set(data[0][i], i);
|
||||
}
|
||||
|
||||
for (let ti = 0; ti < tables.length; ti++) {
|
||||
let t = tables[ti];
|
||||
let xs = t[0];
|
||||
|
||||
for (let si = 1; si < t.length; si++) {
|
||||
let ys = t[si];
|
||||
|
||||
let yVals = Array(alignedLen).fill(undefined);
|
||||
|
||||
let nullMode = nullModes ? nullModes[ti][si] : NULL_RETAIN;
|
||||
|
||||
let nullIdxs = [];
|
||||
|
||||
for (let i = 0; i < ys.length; i++) {
|
||||
let yVal = ys[i];
|
||||
let alignedIdx = xIdxs.get(xs[i]);
|
||||
|
||||
if (yVal == null) {
|
||||
if (nullMode !== NULL_REMOVE) {
|
||||
yVals[alignedIdx] = yVal;
|
||||
|
||||
if (nullMode === NULL_EXPAND) {
|
||||
nullIdxs.push(alignedIdx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yVals[alignedIdx] = yVal;
|
||||
}
|
||||
}
|
||||
|
||||
nullExpand(yVals, nullIdxs, alignedLen);
|
||||
|
||||
data.push(yVals);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Quick test if the first and last points look to be ascending
|
||||
// Only exported for tests
|
||||
export function isLikelyAscendingVector(data: Vector): boolean {
|
||||
let first: any = undefined;
|
||||
|
||||
for (let idx = 0; idx < data.length; idx++) {
|
||||
const v = data.get(idx);
|
||||
if (v != null) {
|
||||
if (first != null) {
|
||||
if (first > v) {
|
||||
return false; // descending
|
||||
}
|
||||
break;
|
||||
}
|
||||
first = v;
|
||||
}
|
||||
}
|
||||
|
||||
let idx = data.length - 1;
|
||||
while (idx >= 0) {
|
||||
const v = data.get(idx--);
|
||||
if (v != null) {
|
||||
if (first > v) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true; // only one non-null point
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArrayVector,
|
||||
DataTransformerConfig,
|
||||
DataTransformerID,
|
||||
Field,
|
||||
FieldType,
|
||||
toDataFrame,
|
||||
transformDataFrame,
|
||||
@@ -45,58 +44,94 @@ describe('SeriesToColumns Transformer', () => {
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.fields).toEqual([
|
||||
{
|
||||
name: 'time',
|
||||
state: {
|
||||
displayName: 'time',
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "time",
|
||||
"state": Object {
|
||||
"displayName": "time",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1000,
|
||||
3000,
|
||||
4000,
|
||||
5000,
|
||||
6000,
|
||||
7000,
|
||||
],
|
||||
},
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 3000, 4000, 5000, 6000, 7000]),
|
||||
config: {},
|
||||
labels: undefined,
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature even',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "even",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
undefined,
|
||||
10.3,
|
||||
10.4,
|
||||
10.5,
|
||||
10.6,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([null, 10.3, 10.4, 10.5, 10.6, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity even',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "even",
|
||||
},
|
||||
"name": "humidity",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
undefined,
|
||||
10000.3,
|
||||
10000.4,
|
||||
10000.5,
|
||||
10000.6,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([null, 10000.3, 10000.4, 10000.5, 10000.6, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature odd',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "odd",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
11.1,
|
||||
11.3,
|
||||
undefined,
|
||||
11.5,
|
||||
undefined,
|
||||
11.7,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([11.1, 11.3, null, 11.5, null, 11.7]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity odd',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "odd",
|
||||
},
|
||||
"name": "humidity",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
11000.1,
|
||||
11000.3,
|
||||
undefined,
|
||||
11000.5,
|
||||
undefined,
|
||||
11000.7,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([11000.1, 11000.3, null, 11000.5, null, 11000.7]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
]);
|
||||
]
|
||||
`);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -113,58 +148,7 @@ describe('SeriesToColumns Transformer', () => {
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.fields).toEqual([
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([10.3, 10.4, 10.5, 10.6, 11.1, 11.3, 11.5, 11.7]),
|
||||
config: {},
|
||||
labels: undefined,
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
state: {
|
||||
displayName: 'time even',
|
||||
},
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([3000, 4000, 5000, 6000, null, null, null, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity even',
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6, null, null, null, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
state: {
|
||||
displayName: 'time odd',
|
||||
},
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([null, null, null, null, 1000, 3000, 5000, 7000]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity odd',
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([null, null, null, null, 11000.1, 11000.3, 11000.5, 11000.7]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
]);
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`Array []`);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -185,58 +169,94 @@ describe('SeriesToColumns Transformer', () => {
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.fields).toEqual([
|
||||
{
|
||||
name: 'time',
|
||||
state: {
|
||||
displayName: 'time',
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "time",
|
||||
"state": Object {
|
||||
"displayName": "time",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1000,
|
||||
3000,
|
||||
4000,
|
||||
5000,
|
||||
6000,
|
||||
7000,
|
||||
],
|
||||
},
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 3000, 4000, 5000, 6000, 7000]),
|
||||
config: {},
|
||||
labels: undefined,
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature even',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "even",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
undefined,
|
||||
10.3,
|
||||
10.4,
|
||||
10.5,
|
||||
10.6,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([null, 10.3, 10.4, 10.5, 10.6, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity even',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "even",
|
||||
},
|
||||
"name": "humidity",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
undefined,
|
||||
10000.3,
|
||||
10000.4,
|
||||
10000.5,
|
||||
10000.6,
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([null, 10000.3, 10000.4, 10000.5, 10000.6, null]),
|
||||
config: {},
|
||||
labels: { name: 'even' },
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature odd',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "odd",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
11.1,
|
||||
11.3,
|
||||
undefined,
|
||||
11.5,
|
||||
undefined,
|
||||
11.7,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([11.1, 11.3, null, 11.5, null, 11.7]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
{
|
||||
name: 'humidity',
|
||||
state: {
|
||||
displayName: 'humidity odd',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "odd",
|
||||
},
|
||||
"name": "humidity",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
11000.1,
|
||||
11000.3,
|
||||
undefined,
|
||||
11000.5,
|
||||
undefined,
|
||||
11000.7,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([11000.1, 11000.3, null, 11000.5, null, 11000.7]),
|
||||
config: {},
|
||||
labels: { name: 'odd' },
|
||||
},
|
||||
]);
|
||||
]
|
||||
`);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -270,40 +290,54 @@ describe('SeriesToColumns Transformer', () => {
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
const expected: Field[] = [
|
||||
{
|
||||
name: 'time',
|
||||
state: {
|
||||
displayName: 'time',
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "time",
|
||||
"state": Object {
|
||||
"displayName": "time",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1000,
|
||||
2000,
|
||||
3000,
|
||||
4000,
|
||||
],
|
||||
},
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
config: {},
|
||||
labels: undefined,
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature temperature',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "temperature",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
],
|
||||
},
|
||||
labels: { name: 'temperature' },
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: {
|
||||
displayName: 'temperature B',
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "B",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
8,
|
||||
],
|
||||
},
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([2, 4, 6, 8]),
|
||||
config: {},
|
||||
labels: { name: 'B' },
|
||||
},
|
||||
];
|
||||
|
||||
expect(filtered.fields).toEqual(expected);
|
||||
]
|
||||
`);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -341,31 +375,51 @@ describe('SeriesToColumns Transformer', () => {
|
||||
await expect(transformDataFrame([cfg], [frame1, frame2, frame3])).toEmitValuesWith((received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.fields).toEqual([
|
||||
{
|
||||
name: 'time',
|
||||
state: { displayName: 'time' },
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1, 2, 3]),
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: { displayName: 'temperature A' },
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([10, 11, 12]),
|
||||
config: {},
|
||||
labels: { name: 'A' },
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: { displayName: 'temperature C' },
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([20, 22, 24]),
|
||||
config: {},
|
||||
labels: { name: 'C' },
|
||||
},
|
||||
]);
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "time",
|
||||
"state": Object {
|
||||
"displayName": "time",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "A",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {
|
||||
"name": "C",
|
||||
},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
20,
|
||||
22,
|
||||
24,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,31 +448,41 @@ describe('SeriesToColumns Transformer', () => {
|
||||
await expect(transformDataFrame([cfg], [frame1, frame2])).toEmitValuesWith((received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.fields).toEqual([
|
||||
{
|
||||
name: 'time',
|
||||
state: { displayName: 'time' },
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1]),
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: { displayName: 'temperature 1' },
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([10]),
|
||||
config: {},
|
||||
labels: {},
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
state: { displayName: 'temperature 2' },
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([20]),
|
||||
config: {},
|
||||
labels: {},
|
||||
},
|
||||
]);
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {},
|
||||
"name": "time",
|
||||
"state": Object {
|
||||
"displayName": "time",
|
||||
},
|
||||
"type": "time",
|
||||
"values": Array [
|
||||
1,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
10,
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"config": Object {},
|
||||
"labels": Object {},
|
||||
"name": "temperature",
|
||||
"state": Object {},
|
||||
"type": "number",
|
||||
"values": Array [
|
||||
20,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,153 +1,36 @@
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { DataFrame, DataTransformerInfo, Field } from '../../types';
|
||||
import { DataTransformerInfo, FieldMatcher } from '../../types';
|
||||
import { DataTransformerID } from './ids';
|
||||
import { MutableDataFrame } from '../../dataframe';
|
||||
import { ArrayVector } from '../../vector';
|
||||
import { getFieldDisplayName } from '../../field/fieldState';
|
||||
import { outerJoinDataFrames } from './joinDataFrames';
|
||||
import { fieldMatchers } from '../matchers';
|
||||
import { FieldMatcherID } from '../matchers/ids';
|
||||
|
||||
export interface SeriesToColumnsOptions {
|
||||
byField?: string;
|
||||
byField?: string; // empty will pick the field automatically
|
||||
}
|
||||
|
||||
const DEFAULT_KEY_FIELD = 'Time';
|
||||
|
||||
export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOptions> = {
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
name: 'Series as columns',
|
||||
name: 'Series as columns', // Called 'Outer join' in the UI!
|
||||
description: 'Groups series by field and returns values as columns',
|
||||
defaultOptions: {
|
||||
byField: DEFAULT_KEY_FIELD,
|
||||
byField: undefined, // DEFAULT_KEY_FIELD,
|
||||
},
|
||||
operator: (options) => (source) =>
|
||||
source.pipe(
|
||||
map((data) => {
|
||||
return outerJoinDataFrames(data, options);
|
||||
if (data.length > 1) {
|
||||
let joinBy: FieldMatcher | undefined = undefined;
|
||||
if (options.byField) {
|
||||
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
|
||||
}
|
||||
const joined = outerJoinDataFrames({ frames: data, joinBy });
|
||||
if (joined) {
|
||||
return [joined];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function outerJoinDataFrames(data: DataFrame[], options: SeriesToColumnsOptions) {
|
||||
const keyFieldMatch = options.byField || DEFAULT_KEY_FIELD;
|
||||
const allFields: FieldsToProcess[] = [];
|
||||
|
||||
for (let frameIndex = 0; frameIndex < data.length; frameIndex++) {
|
||||
const frame = data[frameIndex];
|
||||
const keyField = findKeyField(frame, keyFieldMatch);
|
||||
|
||||
if (!keyField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
|
||||
const sourceField = frame.fields[fieldIndex];
|
||||
|
||||
if (sourceField === keyField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let labels = sourceField.labels ?? {};
|
||||
|
||||
if (frame.name) {
|
||||
labels = { ...labels, name: frame.name };
|
||||
}
|
||||
|
||||
allFields.push({
|
||||
keyField,
|
||||
sourceField,
|
||||
newField: {
|
||||
...sourceField,
|
||||
state: null,
|
||||
values: new ArrayVector([]),
|
||||
labels,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// if no key fields or more than one value field
|
||||
if (allFields.length <= 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const resultFrame = new MutableDataFrame();
|
||||
|
||||
resultFrame.addField({
|
||||
...allFields[0].keyField,
|
||||
values: new ArrayVector([]),
|
||||
});
|
||||
|
||||
for (const item of allFields) {
|
||||
item.newField = resultFrame.addField(item.newField);
|
||||
}
|
||||
|
||||
const keyFieldTitle = getFieldDisplayName(resultFrame.fields[0], resultFrame);
|
||||
const byKeyField: { [key: string]: { [key: string]: any } } = {};
|
||||
|
||||
/*
|
||||
this loop creates a dictionary object that groups the key fields values
|
||||
{
|
||||
"key field first value as string" : {
|
||||
"key field name": key field first value,
|
||||
"other series name": other series value
|
||||
"other series n name": other series n value
|
||||
},
|
||||
"key field n value as string" : {
|
||||
"key field name": key field n value,
|
||||
"other series name": other series value
|
||||
"other series n name": other series n value
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
for (let fieldIndex = 0; fieldIndex < allFields.length; fieldIndex++) {
|
||||
const { sourceField, keyField, newField } = allFields[fieldIndex];
|
||||
const newFieldTitle = getFieldDisplayName(newField, resultFrame);
|
||||
|
||||
for (let valueIndex = 0; valueIndex < sourceField.values.length; valueIndex++) {
|
||||
const value = sourceField.values.get(valueIndex);
|
||||
const keyValue = keyField.values.get(valueIndex);
|
||||
|
||||
if (!byKeyField[keyValue]) {
|
||||
byKeyField[keyValue] = { [newFieldTitle]: value, [keyFieldTitle]: keyValue };
|
||||
} else {
|
||||
byKeyField[keyValue][newFieldTitle] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keyValueStrings = Object.keys(byKeyField);
|
||||
for (let rowIndex = 0; rowIndex < keyValueStrings.length; rowIndex++) {
|
||||
const keyValueAsString = keyValueStrings[rowIndex];
|
||||
|
||||
for (let fieldIndex = 0; fieldIndex < resultFrame.fields.length; fieldIndex++) {
|
||||
const field = resultFrame.fields[fieldIndex];
|
||||
const otherColumnName = getFieldDisplayName(field, resultFrame);
|
||||
const value = byKeyField[keyValueAsString][otherColumnName] ?? null;
|
||||
field.values.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return [resultFrame];
|
||||
}
|
||||
|
||||
function findKeyField(frame: DataFrame, matchTitle: string): Field | null {
|
||||
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
|
||||
const field = frame.fields[fieldIndex];
|
||||
|
||||
if (matchTitle === getFieldDisplayName(field)) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FieldsToProcess {
|
||||
newField: Field;
|
||||
sourceField: Field;
|
||||
keyField: Field;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,21 @@ export interface BuildInfo {
|
||||
*/
|
||||
isEnterprise: boolean;
|
||||
env: string;
|
||||
edition: string;
|
||||
edition: GrafanaEdition;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
hideVersion: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export enum GrafanaEdition {
|
||||
OpenSource = 'Open Source',
|
||||
Pro = 'Pro',
|
||||
Enterprise = 'Enterprise',
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes available feature toggles in Grafana. These can be configured via the
|
||||
* `conf/custom.ini` to enable features under development or not yet available in
|
||||
@@ -54,7 +63,7 @@ export interface LicenseInfo {
|
||||
licenseUrl: string;
|
||||
stateInfo: string;
|
||||
hasValidLicense: boolean;
|
||||
edition: string;
|
||||
edition: GrafanaEdition;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -162,6 +162,13 @@ export interface FieldState {
|
||||
* Useful for assigning color to series by looking up a color in a palette using this index
|
||||
*/
|
||||
seriesIndex?: number;
|
||||
|
||||
/**
|
||||
* Location of this field within the context frames results
|
||||
*
|
||||
* @internal -- we will try to make this unnecessary
|
||||
*/
|
||||
origin?: DataFrameFieldIndex;
|
||||
}
|
||||
|
||||
export interface NumericRange {
|
||||
@@ -206,7 +213,8 @@ export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric';
|
||||
/**
|
||||
* Describes where a specific data frame field is located within a
|
||||
* dataset of type DataFrame[]
|
||||
* @public
|
||||
*
|
||||
* @internal -- we will try to make this unnecessary
|
||||
*/
|
||||
export interface DataFrameFieldIndex {
|
||||
frameIndex: number;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface DataLink<T extends DataQuery = any> {
|
||||
onClick?: (event: DataLinkClickEvent) => void;
|
||||
|
||||
// If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular
|
||||
// datas ource that we want to show to the user. Usually this results in a link to explore but can also lead to
|
||||
// data source that we want to show to the user. Usually this results in a link to explore but can also lead to
|
||||
// more custom onClick behaviour if needed.
|
||||
// @internal and subject to change in future releases
|
||||
internal?: InternalDataLink<T>;
|
||||
|
||||
@@ -186,6 +186,11 @@ export abstract class DataSourceApi<
|
||||
*/
|
||||
readonly type: string;
|
||||
|
||||
/**
|
||||
* Set in constructor
|
||||
*/
|
||||
readonly uid: string;
|
||||
|
||||
/**
|
||||
* min interval range
|
||||
*/
|
||||
@@ -196,6 +201,7 @@ export abstract class DataSourceApi<
|
||||
this.id = instanceSettings.id;
|
||||
this.type = instanceSettings.type;
|
||||
this.meta = {} as DataSourcePluginMeta;
|
||||
this.uid = instanceSettings.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,6 +594,9 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated -- use {@link DataSourceInstanceSettings} instead
|
||||
*/
|
||||
export interface DataSourceSelectItem {
|
||||
name: string;
|
||||
value: string | null;
|
||||
|
||||
7
packages/grafana-data/src/types/geometry.ts
Normal file
7
packages/grafana-data/src/types/geometry.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* A coordinate on a two dimensional plane.
|
||||
*/
|
||||
export interface CartesianCoords2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -29,5 +29,6 @@ export * from './explore';
|
||||
export * from './legacyEvents';
|
||||
export * from './live';
|
||||
export * from './variables';
|
||||
export * from './geometry';
|
||||
|
||||
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DataFrame } from './dataFrame';
|
||||
|
||||
/**
|
||||
* Base class for editor builders
|
||||
*
|
||||
@@ -49,5 +51,5 @@ export interface OptionEditorConfig<TOptions, TSettings = any, TValue = any> {
|
||||
/**
|
||||
* Function that enables configuration of when option editor should be shown based on current panel option properties.
|
||||
*/
|
||||
showIf?: (currentOptions: TOptions) => boolean | undefined;
|
||||
showIf?: (currentOptions: TOptions, data?: DataFrame[]) => boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@ export type PanelMigrationHandler<TOptions = any> = (panel: PanelModel<TOptions>
|
||||
export type PanelTypeChangedHandler<TOptions = any> = (
|
||||
panel: PanelModel<TOptions>,
|
||||
prevPluginId: string,
|
||||
prevOptions: any
|
||||
prevOptions: Record<string, any>,
|
||||
prevFieldConfig: FieldConfigSource
|
||||
) => Partial<TOptions>;
|
||||
|
||||
export type PanelOptionEditorsRegistry = Registry<PanelOptionsEditorItem>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getMappedValue } from './valueMappings';
|
||||
import { getMappedValue, isNumeric } from './valueMappings';
|
||||
import { ValueMapping, MappingType } from '../types';
|
||||
|
||||
describe('Format value with value mappings', () => {
|
||||
@@ -79,14 +79,75 @@ describe('Format value with value mappings', () => {
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
|
||||
});
|
||||
|
||||
it('should map value text to mapping', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, text: 'ELVA', type: MappingType.ValueToText, value: 'elva' },
|
||||
];
|
||||
describe('text mapping', () => {
|
||||
it('should map value text to mapping', () => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
|
||||
{ id: 1, text: 'ELVA', type: MappingType.ValueToText, value: 'elva' },
|
||||
];
|
||||
|
||||
const value = 'elva';
|
||||
const value = 'elva';
|
||||
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('ELVA');
|
||||
expect(getMappedValue(valueMappings, value).text).toEqual('ELVA');
|
||||
});
|
||||
|
||||
it.each`
|
||||
value | expected
|
||||
${'2/0/12'} | ${{ id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' }}
|
||||
${'2/1/12'} | ${undefined}
|
||||
${'2:0'} | ${{ id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' }}
|
||||
${'2:1'} | ${undefined}
|
||||
${'20whatever'} | ${{ id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' }}
|
||||
${'20whateve'} | ${undefined}
|
||||
${'20'} | ${undefined}
|
||||
${'00020.4'} | ${undefined}
|
||||
${'192.168.1.1'} | ${{ id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' }}
|
||||
${'192'} | ${undefined}
|
||||
${'192.168'} | ${undefined}
|
||||
${'192.168.1'} | ${undefined}
|
||||
${'9.90'} | ${{ id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' }}
|
||||
`('numeric-like text mapping, value:${value', ({ value, expected }) => {
|
||||
const valueMappings: ValueMapping[] = [
|
||||
{ id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' },
|
||||
{ id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' },
|
||||
{ id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' },
|
||||
{ id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' },
|
||||
{ id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' },
|
||||
];
|
||||
expect(getMappedValue(valueMappings, value)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNumeric', () => {
|
||||
it.each`
|
||||
value | expected
|
||||
${123} | ${true}
|
||||
${'123'} | ${true}
|
||||
${' 123'} | ${true}
|
||||
${' 123 '} | ${true}
|
||||
${-123.4} | ${true}
|
||||
${'-123.4'} | ${true}
|
||||
${0.41} | ${true}
|
||||
${'.41'} | ${true}
|
||||
${0x12} | ${true}
|
||||
${'0x12'} | ${true}
|
||||
${'000123.4'} | ${true}
|
||||
${2e64} | ${true}
|
||||
${'2e64'} | ${true}
|
||||
${1e10000} | ${true}
|
||||
${'1e10000'} | ${true}
|
||||
${Infinity} | ${true}
|
||||
${'abc'} | ${false}
|
||||
${' '} | ${false}
|
||||
${null} | ${false}
|
||||
${undefined} | ${false}
|
||||
${NaN} | ${false}
|
||||
${''} | ${false}
|
||||
${{}} | ${false}
|
||||
${true} | ${false}
|
||||
${[]} | ${false}
|
||||
`('detects numeric values', ({ value, expected }) => {
|
||||
expect(isNumeric(value)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,20 +15,23 @@ const addValueToTextMappingText = (
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
}
|
||||
|
||||
const valueAsNumber = parseFloat(value as string);
|
||||
const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
|
||||
let valueAsNumber, valueToTextMappingAsNumber;
|
||||
|
||||
if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
|
||||
if (value === valueToTextMapping.value) {
|
||||
if (isNumeric(value as string) && isNumeric(valueToTextMapping.value)) {
|
||||
valueAsNumber = parseFloat(value as string);
|
||||
valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
|
||||
|
||||
if (valueAsNumber === valueToTextMappingAsNumber) {
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
}
|
||||
}
|
||||
|
||||
if (valueAsNumber !== valueToTextMappingAsNumber) {
|
||||
return allValueMappings;
|
||||
}
|
||||
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
if (value === valueToTextMapping.value) {
|
||||
return allValueMappings.concat(valueToTextMapping);
|
||||
}
|
||||
|
||||
return allValueMappings;
|
||||
};
|
||||
|
||||
const addRangeToTextMappingText = (
|
||||
@@ -93,3 +96,12 @@ const isNullValueMap = (mapping: ValueMap): boolean => {
|
||||
}
|
||||
return mapping.value.toLowerCase() === 'null';
|
||||
};
|
||||
|
||||
// Ref https://stackoverflow.com/a/42356340
|
||||
export function isNumeric(num: any) {
|
||||
if (num === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(Number(num));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const INTERVALS_IN_SECONDS: IntervalsInSeconds = {
|
||||
[Interval.Millisecond]: 0.001,
|
||||
};
|
||||
|
||||
export function toNanoSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toNanoSeconds(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -39,21 +39,21 @@ export function toNanoSeconds(size: number, decimals?: DecimalCount, scaledDecim
|
||||
if (Math.abs(size) < 1000) {
|
||||
return { text: toFixed(size, decimals), suffix: ' ns' };
|
||||
} else if (Math.abs(size) < 1000000) {
|
||||
return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' µs');
|
||||
return toFixedScaled(size / 1000, decimals, ' µs');
|
||||
} else if (Math.abs(size) < 1000000000) {
|
||||
return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' ms');
|
||||
return toFixedScaled(size / 1000000, decimals, ' ms');
|
||||
} else if (Math.abs(size) < 60000000000) {
|
||||
return toFixedScaled(size / 1000000000, decimals, scaledDecimals, 9, ' s');
|
||||
return toFixedScaled(size / 1000000000, decimals, ' s');
|
||||
} else if (Math.abs(size) < 3600000000000) {
|
||||
return toFixedScaled(size / 60000000000, decimals, scaledDecimals, 12, ' min');
|
||||
return toFixedScaled(size / 60000000000, decimals, ' min');
|
||||
} else if (Math.abs(size) < 86400000000000) {
|
||||
return toFixedScaled(size / 3600000000000, decimals, scaledDecimals, 13, ' hour');
|
||||
return toFixedScaled(size / 3600000000000, decimals, ' hour');
|
||||
} else {
|
||||
return toFixedScaled(size / 86400000000000, decimals, scaledDecimals, 14, ' day');
|
||||
return toFixedScaled(size / 86400000000000, decimals, ' day');
|
||||
}
|
||||
}
|
||||
|
||||
export function toMicroSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toMicroSeconds(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -61,9 +61,9 @@ export function toMicroSeconds(size: number, decimals?: DecimalCount, scaledDeci
|
||||
if (Math.abs(size) < 1000) {
|
||||
return { text: toFixed(size, decimals), suffix: ' µs' };
|
||||
} else if (Math.abs(size) < 1000000) {
|
||||
return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' ms');
|
||||
return toFixedScaled(size / 1000, decimals, ' ms');
|
||||
} else {
|
||||
return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' s');
|
||||
return toFixedScaled(size / 1000000, decimals, ' s');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,19 +76,19 @@ export function toMilliSeconds(size: number, decimals?: DecimalCount, scaledDeci
|
||||
return { text: toFixed(size, decimals), suffix: ' ms' };
|
||||
} else if (Math.abs(size) < 60000) {
|
||||
// Less than 1 min
|
||||
return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' s');
|
||||
return toFixedScaled(size / 1000, decimals, ' s');
|
||||
} else if (Math.abs(size) < 3600000) {
|
||||
// Less than 1 hour, divide in minutes
|
||||
return toFixedScaled(size / 60000, decimals, scaledDecimals, 5, ' min');
|
||||
return toFixedScaled(size / 60000, decimals, ' min');
|
||||
} else if (Math.abs(size) < 86400000) {
|
||||
// Less than one day, divide in hours
|
||||
return toFixedScaled(size / 3600000, decimals, scaledDecimals, 7, ' hour');
|
||||
return toFixedScaled(size / 3600000, decimals, ' hour');
|
||||
} else if (Math.abs(size) < 31536000000) {
|
||||
// Less than one year, divide in days
|
||||
return toFixedScaled(size / 86400000, decimals, scaledDecimals, 8, ' day');
|
||||
return toFixedScaled(size / 86400000, decimals, ' day');
|
||||
}
|
||||
|
||||
return toFixedScaled(size / 31536000000, decimals, scaledDecimals, 10, ' year');
|
||||
return toFixedScaled(size / 31536000000, decimals, ' year');
|
||||
}
|
||||
|
||||
export function trySubstract(value1: DecimalCount, value2: DecimalCount): DecimalCount {
|
||||
@@ -98,7 +98,7 @@ export function trySubstract(value1: DecimalCount, value2: DecimalCount): Decima
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toSeconds(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -110,37 +110,37 @@ export function toSeconds(size: number, decimals?: DecimalCount, scaledDecimals?
|
||||
|
||||
// Less than 1 µs, divide in ns
|
||||
if (Math.abs(size) < 0.000001) {
|
||||
return toFixedScaled(size * 1e9, decimals, trySubstract(scaledDecimals, decimals), -9, ' ns');
|
||||
return toFixedScaled(size * 1e9, decimals, ' ns');
|
||||
}
|
||||
// Less than 1 ms, divide in µs
|
||||
if (Math.abs(size) < 0.001) {
|
||||
return toFixedScaled(size * 1e6, decimals, trySubstract(scaledDecimals, decimals), -6, ' µs');
|
||||
return toFixedScaled(size * 1e6, decimals, ' µs');
|
||||
}
|
||||
// Less than 1 second, divide in ms
|
||||
if (Math.abs(size) < 1) {
|
||||
return toFixedScaled(size * 1e3, decimals, trySubstract(scaledDecimals, decimals), -3, ' ms');
|
||||
return toFixedScaled(size * 1e3, decimals, ' ms');
|
||||
}
|
||||
|
||||
if (Math.abs(size) < 60) {
|
||||
return { text: toFixed(size, decimals), suffix: ' s' };
|
||||
} else if (Math.abs(size) < 3600) {
|
||||
// Less than 1 hour, divide in minutes
|
||||
return toFixedScaled(size / 60, decimals, scaledDecimals, 1, ' min');
|
||||
return toFixedScaled(size / 60, decimals, ' min');
|
||||
} else if (Math.abs(size) < 86400) {
|
||||
// Less than one day, divide in hours
|
||||
return toFixedScaled(size / 3600, decimals, scaledDecimals, 4, ' hour');
|
||||
return toFixedScaled(size / 3600, decimals, ' hour');
|
||||
} else if (Math.abs(size) < 604800) {
|
||||
// Less than one week, divide in days
|
||||
return toFixedScaled(size / 86400, decimals, scaledDecimals, 5, ' day');
|
||||
return toFixedScaled(size / 86400, decimals, ' day');
|
||||
} else if (Math.abs(size) < 31536000) {
|
||||
// Less than one year, divide in week
|
||||
return toFixedScaled(size / 604800, decimals, scaledDecimals, 6, ' week');
|
||||
return toFixedScaled(size / 604800, decimals, ' week');
|
||||
}
|
||||
|
||||
return toFixedScaled(size / 3.15569e7, decimals, scaledDecimals, 7, ' year');
|
||||
return toFixedScaled(size / 3.15569e7, decimals, ' year');
|
||||
}
|
||||
|
||||
export function toMinutes(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toMinutes(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -148,17 +148,17 @@ export function toMinutes(size: number, decimals?: DecimalCount, scaledDecimals?
|
||||
if (Math.abs(size) < 60) {
|
||||
return { text: toFixed(size, decimals), suffix: ' min' };
|
||||
} else if (Math.abs(size) < 1440) {
|
||||
return toFixedScaled(size / 60, decimals, scaledDecimals, 2, ' hour');
|
||||
return toFixedScaled(size / 60, decimals, ' hour');
|
||||
} else if (Math.abs(size) < 10080) {
|
||||
return toFixedScaled(size / 1440, decimals, scaledDecimals, 3, ' day');
|
||||
return toFixedScaled(size / 1440, decimals, ' day');
|
||||
} else if (Math.abs(size) < 604800) {
|
||||
return toFixedScaled(size / 10080, decimals, scaledDecimals, 4, ' week');
|
||||
return toFixedScaled(size / 10080, decimals, ' week');
|
||||
} else {
|
||||
return toFixedScaled(size / 5.25948e5, decimals, scaledDecimals, 5, ' year');
|
||||
return toFixedScaled(size / 5.25948e5, decimals, ' year');
|
||||
}
|
||||
}
|
||||
|
||||
export function toHours(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toHours(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -166,15 +166,15 @@ export function toHours(size: number, decimals?: DecimalCount, scaledDecimals?:
|
||||
if (Math.abs(size) < 24) {
|
||||
return { text: toFixed(size, decimals), suffix: ' hour' };
|
||||
} else if (Math.abs(size) < 168) {
|
||||
return toFixedScaled(size / 24, decimals, scaledDecimals, 2, ' day');
|
||||
return toFixedScaled(size / 24, decimals, ' day');
|
||||
} else if (Math.abs(size) < 8760) {
|
||||
return toFixedScaled(size / 168, decimals, scaledDecimals, 3, ' week');
|
||||
return toFixedScaled(size / 168, decimals, ' week');
|
||||
} else {
|
||||
return toFixedScaled(size / 8760, decimals, scaledDecimals, 4, ' year');
|
||||
return toFixedScaled(size / 8760, decimals, ' year');
|
||||
}
|
||||
}
|
||||
|
||||
export function toDays(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue {
|
||||
export function toDays(size: number, decimals?: DecimalCount): FormattedValue {
|
||||
if (size === null) {
|
||||
return { text: '' };
|
||||
}
|
||||
@@ -182,9 +182,9 @@ export function toDays(size: number, decimals?: DecimalCount, scaledDecimals?: D
|
||||
if (Math.abs(size) < 7) {
|
||||
return { text: toFixed(size, decimals), suffix: ' day' };
|
||||
} else if (Math.abs(size) < 365) {
|
||||
return toFixedScaled(size / 7, decimals, scaledDecimals, 2, ' week');
|
||||
return toFixedScaled(size / 7, decimals, ' week');
|
||||
} else {
|
||||
return toFixedScaled(size / 365, decimals, scaledDecimals, 3, ' year');
|
||||
return toFixedScaled(size / 365, decimals, ' year');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ export function toDuration(size: number, decimals: DecimalCount, timeScale: Inte
|
||||
let decrementDecimals = false;
|
||||
let decimalsCount = 0;
|
||||
|
||||
if (decimals !== null || decimals !== undefined) {
|
||||
if (decimals !== null && decimals !== undefined) {
|
||||
decimalsCount = decimals as number;
|
||||
}
|
||||
|
||||
@@ -340,8 +340,8 @@ export function toDurationInDaysHoursMinutesSeconds(size: number): FormattedValu
|
||||
return { text: dayString + hmsString.text };
|
||||
}
|
||||
|
||||
export function toTimeTicks(size: number, decimals: DecimalCount, scaledDecimals: DecimalCount): FormattedValue {
|
||||
return toSeconds(size / 100, decimals, scaledDecimals);
|
||||
export function toTimeTicks(size: number, decimals: DecimalCount): FormattedValue {
|
||||
return toSeconds(size / 100, decimals);
|
||||
}
|
||||
|
||||
export function toClockMilliseconds(size: number, decimals: DecimalCount): FormattedValue {
|
||||
|
||||
@@ -12,77 +12,69 @@ interface ValueFormatTest {
|
||||
result: string;
|
||||
}
|
||||
|
||||
const formatTests: ValueFormatTest[] = [
|
||||
// Currency
|
||||
{ id: 'currencyUSD', decimals: 2, value: 1532.82, result: '$1.53K' },
|
||||
{ id: 'currencyKRW', decimals: 2, value: 1532.82, result: '₩1.53K' },
|
||||
{ id: 'currencyIDR', decimals: 2, value: 1532.82, result: 'Rp1.53K' },
|
||||
|
||||
// Standard
|
||||
{ id: 'ms', decimals: 4, value: 0.0024, result: '0.0024 ms' },
|
||||
{ id: 'ms', decimals: 0, value: 100, result: '100 ms' },
|
||||
{ id: 'ms', decimals: 2, value: 1250, result: '1.25 s' },
|
||||
{ id: 'ms', decimals: 1, value: 10000086.123, result: '2.8 hour' },
|
||||
{ id: 'ms', decimals: 0, value: 1200, result: '1 s' },
|
||||
{ id: 'short', decimals: 0, scaledDecimals: -1, value: 98765, result: '98.77 K' },
|
||||
{ id: 'short', decimals: 0, scaledDecimals: 0, value: 9876543, result: '9.876543 Mil' },
|
||||
{ id: 'short', decimals: 2, scaledDecimals: null, value: 9876543, result: '9.88 Mil' },
|
||||
{ id: 'kbytes', decimals: 3, value: 10000000, result: '9.537 GiB' },
|
||||
{ id: 'deckbytes', decimals: 3, value: 10000000, result: '10.000 GB' },
|
||||
{ id: 'megwatt', decimals: 3, value: 1000, result: '1.000 GW' },
|
||||
{ id: 'kohm', decimals: 3, value: 1000, result: '1.000 MΩ' },
|
||||
{ id: 'Mohm', decimals: 3, value: 1000, result: '1.000 GΩ' },
|
||||
|
||||
{ id: 'farad', decimals: 3, value: 1000, result: '1.000 kF' },
|
||||
{ id: 'µfarad', decimals: 3, value: 1000, result: '1.000 mF' },
|
||||
{ id: 'nfarad', decimals: 3, value: 1000, result: '1.000 µF' },
|
||||
{ id: 'pfarad', decimals: 3, value: 1000, result: '1.000 nF' },
|
||||
{ id: 'ffarad', decimals: 3, value: 1000, result: '1.000 pF' },
|
||||
|
||||
{ id: 'henry', decimals: 3, value: 1000, result: '1.000 kH' },
|
||||
{ id: 'mhenry', decimals: 3, value: 1000, result: '1.000 H' },
|
||||
{ id: 'µhenry', decimals: 3, value: 1000, result: '1.000 mH' },
|
||||
|
||||
// Suffix (unknown units append to the end)
|
||||
{ id: 'a', decimals: 0, value: 1532.82, result: '1533 a' },
|
||||
{ id: 'b', decimals: 0, value: 1532.82, result: '1533 b' },
|
||||
|
||||
// Prefix (unknown units append to the end)
|
||||
{ id: 'prefix:b', value: 1532.82, result: 'b1533' },
|
||||
{ id: 'suffix:d', value: 1532.82, result: '1533 d' },
|
||||
|
||||
// SI Units
|
||||
{ id: 'si:µF', value: 1234, decimals: 2, result: '1.23 mF' },
|
||||
{ id: 'si:µF', value: 1234000000, decimals: 2, result: '1.23 kF' },
|
||||
{ id: 'si:µF', value: 1234000000000000, decimals: 2, result: '1.23 GF' },
|
||||
|
||||
// Counts (suffix)
|
||||
{ id: 'count:xpm', value: 1234567, decimals: 2, result: '1.23M xpm' },
|
||||
{ id: 'count:x/min', value: 1234, decimals: 2, result: '1.23K x/min' },
|
||||
|
||||
// Currency (prefix)
|
||||
{ id: 'currency:@', value: 1234567, decimals: 2, result: '@1.23M' },
|
||||
{ id: 'currency:@', value: 1234, decimals: 2, result: '@1.23K' },
|
||||
|
||||
// Time format
|
||||
{ id: 'time:YYYY', decimals: 0, value: dateTime(new Date(1999, 6, 2)).valueOf(), result: '1999' },
|
||||
{ id: 'time:YYYY.MM', decimals: 0, value: dateTime(new Date(2010, 6, 2)).valueOf(), result: '2010.07' },
|
||||
{ id: 'dateTimeAsIso', decimals: 0, value: dateTime(new Date(2010, 6, 2)).valueOf(), result: '2010-07-02 00:00:00' },
|
||||
{
|
||||
id: 'dateTimeAsUS',
|
||||
decimals: 0,
|
||||
value: dateTime(new Date(2010, 6, 2)).valueOf(),
|
||||
result: '07/02/2010 12:00:00 am',
|
||||
},
|
||||
{
|
||||
id: 'dateTimeAsSystem',
|
||||
decimals: 0,
|
||||
value: dateTime(new Date(2010, 6, 2)).valueOf(),
|
||||
result: '2010-07-02 00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
describe('valueFormats', () => {
|
||||
it.each`
|
||||
format | decimals | value | expected
|
||||
${'currencyUSD'} | ${2} | ${1532.82} | ${'$1.53K'}
|
||||
${'currencyKRW'} | ${2} | ${1532.82} | ${'₩1.53K'}
|
||||
${'currencyIDR'} | ${2} | ${1532.82} | ${'Rp1.53K'}
|
||||
${'none'} | ${undefined} | ${3.23} | ${'3.23'}
|
||||
${'none'} | ${undefined} | ${0.0245} | ${'0.0245'}
|
||||
${'none'} | ${undefined} | ${1 / 3} | ${'0.333'}
|
||||
${'ms'} | ${4} | ${0.0024} | ${'0.0024 ms'}
|
||||
${'ms'} | ${0} | ${100} | ${'100 ms'}
|
||||
${'ms'} | ${2} | ${1250} | ${'1.25 s'}
|
||||
${'ms'} | ${1} | ${10000086.123} | ${'2.8 hour'}
|
||||
${'ms'} | ${undefined} | ${1000} | ${'1 s'}
|
||||
${'ms'} | ${0} | ${1200} | ${'1 s'}
|
||||
${'short'} | ${undefined} | ${1000} | ${'1 K'}
|
||||
${'short'} | ${undefined} | ${1200} | ${'1.20 K'}
|
||||
${'short'} | ${undefined} | ${1250} | ${'1.25 K'}
|
||||
${'short'} | ${undefined} | ${1000000} | ${'1 Mil'}
|
||||
${'short'} | ${undefined} | ${1500000} | ${'1.50 Mil'}
|
||||
${'short'} | ${undefined} | ${1000120} | ${'1.00 Mil'}
|
||||
${'short'} | ${undefined} | ${98765} | ${'98.8 K'}
|
||||
${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'}
|
||||
${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'}
|
||||
${'kbytes'} | ${undefined} | ${10000000} | ${'9.54 GiB'}
|
||||
${'deckbytes'} | ${undefined} | ${10000000} | ${'10 GB'}
|
||||
${'megwatt'} | ${3} | ${1000} | ${'1.000 GW'}
|
||||
${'kohm'} | ${3} | ${1000} | ${'1.000 MΩ'}
|
||||
${'Mohm'} | ${3} | ${1000} | ${'1.000 GΩ'}
|
||||
${'farad'} | ${3} | ${1000} | ${'1.000 kF'}
|
||||
${'µfarad'} | ${3} | ${1000} | ${'1.000 mF'}
|
||||
${'nfarad'} | ${3} | ${1000} | ${'1.000 µF'}
|
||||
${'pfarad'} | ${3} | ${1000} | ${'1.000 nF'}
|
||||
${'ffarad'} | ${3} | ${1000} | ${'1.000 pF'}
|
||||
${'henry'} | ${3} | ${1000} | ${'1.000 kH'}
|
||||
${'mhenry'} | ${3} | ${1000} | ${'1.000 H'}
|
||||
${'µhenry'} | ${3} | ${1000} | ${'1.000 mH'}
|
||||
${'a'} | ${0} | ${1532.82} | ${'1533 a'}
|
||||
${'b'} | ${0} | ${1532.82} | ${'1533 b'}
|
||||
${'prefix:b'} | ${undefined} | ${1532.82} | ${'b1533'}
|
||||
${'suffix:d'} | ${undefined} | ${1532.82} | ${'1533 d'}
|
||||
${'si:µF'} | ${2} | ${1234} | ${'1.23 mF'}
|
||||
${'si:µF'} | ${2} | ${1234000000} | ${'1.23 kF'}
|
||||
${'si:µF'} | ${2} | ${1234000000000000} | ${'1.23 GF'}
|
||||
${'count:xpm'} | ${2} | ${1234567} | ${'1.23M xpm'}
|
||||
${'count:x/min'} | ${2} | ${1234} | ${'1.23K x/min'}
|
||||
${'currency:@'} | ${2} | ${1234567} | ${'@1.23M'}
|
||||
${'currency:@'} | ${2} | ${1234} | ${'@1.23K'}
|
||||
${'time:YYYY'} | ${0} | ${dateTime(new Date(1999, 6, 2)).valueOf()} | ${'1999'}
|
||||
${'time:YYYY.MM'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010.07'}
|
||||
${'dateTimeAsIso'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'}
|
||||
${'dateTimeAsUS'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'07/02/2010 12:00:00 am'}
|
||||
${'dateTimeAsSystem'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'}
|
||||
${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'}
|
||||
`(
|
||||
'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected',
|
||||
async ({ format, value, decimals, expected }) => {
|
||||
const result = getValueFormat(format)(value, decimals, undefined, undefined);
|
||||
const full = formattedValueToString(result);
|
||||
expect(full).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('Manually check a format', () => {
|
||||
// helpful for adding tests one at a time with the debugger
|
||||
const tests: ValueFormatTest[] = [
|
||||
@@ -94,16 +86,6 @@ describe('valueFormats', () => {
|
||||
expect(full).toBe(test.result);
|
||||
});
|
||||
|
||||
for (const test of formatTests) {
|
||||
describe(`value format: ${test.id}`, () => {
|
||||
it(`should translate ${test.value} as ${test.result}`, () => {
|
||||
const result = getValueFormat(test.id)(test.value, test.decimals, test.scaledDecimals);
|
||||
const full = formattedValueToString(result);
|
||||
expect(full).toBe(test.result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('normal cases', () => {
|
||||
it('toFixed should handle number correctly if decimal is null', () => {
|
||||
expect(toFixed(100)).toBe('100');
|
||||
|
||||
@@ -45,10 +45,15 @@ export function toFixed(value: number, decimals?: DecimalCount): string {
|
||||
if (value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value === Number.NEGATIVE_INFINITY || value === Number.POSITIVE_INFINITY) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
if (decimals === null || decimals === undefined) {
|
||||
decimals = getDecimalsForValue(value);
|
||||
}
|
||||
|
||||
const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1;
|
||||
const formatted = String(Math.round(value * factor) / factor);
|
||||
|
||||
@@ -57,31 +62,37 @@ export function toFixed(value: number, decimals?: DecimalCount): string {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// If tickDecimals was specified, ensure that we have exactly that
|
||||
// much precision; otherwise default to the value's own precision.
|
||||
if (decimals != null) {
|
||||
const decimalPos = formatted.indexOf('.');
|
||||
const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1;
|
||||
if (precision < decimals) {
|
||||
return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision);
|
||||
}
|
||||
const decimalPos = formatted.indexOf('.');
|
||||
const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1;
|
||||
if (precision < decimals) {
|
||||
return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision);
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function toFixedScaled(
|
||||
value: number,
|
||||
decimals: DecimalCount,
|
||||
scaledDecimals: DecimalCount,
|
||||
additionalDecimals: number,
|
||||
ext?: string
|
||||
): FormattedValue {
|
||||
if (scaledDecimals === null || scaledDecimals === undefined) {
|
||||
return { text: toFixed(value, decimals), suffix: ext };
|
||||
function getDecimalsForValue(value: number): number {
|
||||
const log10 = Math.floor(Math.log(Math.abs(value)) / Math.LN10);
|
||||
let dec = -log10 + 1;
|
||||
const magn = Math.pow(10, -dec);
|
||||
const norm = value / magn; // norm is between 1.0 and 10.0
|
||||
|
||||
// special case for 2.5, requires an extra decimal
|
||||
if (norm > 2.25) {
|
||||
++dec;
|
||||
}
|
||||
|
||||
if (value % 1 === 0) {
|
||||
dec = 0;
|
||||
}
|
||||
|
||||
const decimals = Math.max(0, dec);
|
||||
return decimals;
|
||||
}
|
||||
|
||||
export function toFixedScaled(value: number, decimals: DecimalCount, ext?: string): FormattedValue {
|
||||
return {
|
||||
text: toFixed(value, scaledDecimals + additionalDecimals),
|
||||
text: toFixed(value, decimals),
|
||||
suffix: ext,
|
||||
};
|
||||
}
|
||||
@@ -126,10 +137,6 @@ export function scaledUnits(factor: number, extArray: string[]): ValueFormatter
|
||||
}
|
||||
}
|
||||
|
||||
if (steps > 0 && scaledDecimals !== null && scaledDecimals !== undefined) {
|
||||
decimals = scaledDecimals + 3 * steps;
|
||||
}
|
||||
|
||||
return { text: toFixed(size, decimals), suffix: extArray[steps] };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { MutableVector } from '../types/vector';
|
||||
import { FunctionalVector } from './FunctionalVector';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class ArrayVector<T = any> extends FunctionalVector<T> implements MutableVector<T> {
|
||||
buffer: T[];
|
||||
|
||||
|
||||
21
packages/grafana-data/src/vector/AsNumberVector.ts
Normal file
21
packages/grafana-data/src/vector/AsNumberVector.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Vector } from '../types';
|
||||
import { FunctionalVector } from './FunctionalVector';
|
||||
|
||||
/**
|
||||
* This will force all values to be numbers
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class AsNumberVector extends FunctionalVector<number> {
|
||||
constructor(private field: Vector) {
|
||||
super();
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.field.length;
|
||||
}
|
||||
|
||||
get(index: number) {
|
||||
return +this.field.get(index);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { Vector } from '../types/vector';
|
||||
import { vectorToArray } from './vectorToArray';
|
||||
import { BinaryOperation } from '../utils/binaryOperators';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class BinaryOperationVector implements Vector<number> {
|
||||
constructor(private left: Vector<number>, private right: Vector<number>, private operation: BinaryOperation) {}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ interface CircularOptions<T> {
|
||||
*
|
||||
* This supports adding to the 'head' or 'tail' and will grow the buffer
|
||||
* to match a configured capacity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CircularVector<T = any> extends FunctionalVector<T> implements MutableVector<T> {
|
||||
private buffer: T[];
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Vector } from '../types/vector';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class ConstantVector<T = any> implements Vector<T> {
|
||||
constructor(private value: T, private len: number) {}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Vector } from '../types/vector';
|
||||
import { DisplayProcessor } from '../types';
|
||||
import { formattedValueToString } from '../valueFormats';
|
||||
import { vectorToArray } from './vectorToArray';
|
||||
import { FunctionalVector } from './FunctionalVector';
|
||||
|
||||
export class FormattedVector<T = any> implements Vector<string> {
|
||||
constructor(private source: Vector<T>, private formatter: DisplayProcessor) {}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class FormattedVector<T = any> extends FunctionalVector<string> {
|
||||
constructor(private source: Vector<T>, private formatter: DisplayProcessor) {
|
||||
super();
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.source.length;
|
||||
@@ -14,12 +19,4 @@ export class FormattedVector<T = any> implements Vector<string> {
|
||||
const v = this.source.get(index);
|
||||
return formattedValueToString(this.formatter(v));
|
||||
}
|
||||
|
||||
toArray(): string[] {
|
||||
return vectorToArray(this);
|
||||
}
|
||||
|
||||
toJSON(): string[] {
|
||||
return this.toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ export * from './BinaryOperationVector';
|
||||
export * from './SortedVector';
|
||||
export * from './FormattedVector';
|
||||
export * from './IndexVector';
|
||||
export * from './AsNumberVector';
|
||||
|
||||
export { vectorator } from './FunctionalVector';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e-selectors",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana End-to-End Test Selectors Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,12 +44,12 @@
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@cypress/webpack-preprocessor": "4.1.3",
|
||||
"@grafana/e2e-selectors": "7.4.0-pre.0",
|
||||
"@grafana/e2e-selectors": "7.4.5",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@mochajs/json-file-reporter": "^1.2.0",
|
||||
"blink-diff": "1.0.13",
|
||||
"commander": "5.0.0",
|
||||
"cypress": "^4.12.1",
|
||||
"cypress": "^6.3.0",
|
||||
"cypress-file-upload": "^4.0.7",
|
||||
"execa": "4.0.0",
|
||||
"resolve-as-bin": "2.1.0",
|
||||
|
||||
@@ -37,7 +37,6 @@ interface AddVariableRequired {
|
||||
export type PartialAddVariableConfig = Partial<AddVariableDefault> & AddVariableOptional & AddVariableRequired;
|
||||
export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired;
|
||||
|
||||
// @todo this actually returns type `Cypress.Chainable<AddDashboardConfig>`
|
||||
export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
|
||||
const fullConfig: AddDashboardConfig = {
|
||||
annotations: [],
|
||||
@@ -64,7 +63,7 @@ export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
|
||||
|
||||
fullConfig.variables = addVariables(variables);
|
||||
|
||||
e2e.components.BackButton.backArrow().click();
|
||||
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
|
||||
}
|
||||
|
||||
setDashboardTimeRange(timeRange);
|
||||
@@ -152,29 +151,32 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar
|
||||
e2e.pages.Dashboard.Settings.Variables.List.newButton().click();
|
||||
}
|
||||
|
||||
const { constantValue, dataSource, hide, label, name, query, regex, type } = fullConfig;
|
||||
const { constantValue, dataSource, label, name, query, regex, type } = fullConfig;
|
||||
|
||||
// This field is key to many reactive changes
|
||||
if (type !== VARIABLE_TYPE_QUERY) {
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().select(type);
|
||||
}
|
||||
|
||||
// Avoid '', which is an accepted value
|
||||
if (hide !== undefined) {
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalHideSelect().select(hide);
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.components.Select.singleValue().should('have.text', 'Query').click().type(`${type}{enter}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (label) {
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label);
|
||||
}
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().type(name);
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name);
|
||||
|
||||
if (
|
||||
dataSource &&
|
||||
(type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY)
|
||||
) {
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect().select(dataSource);
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.components.Select.input().should('be.visible').type(`${dataSource}{enter}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (constantValue && type === VARIABLE_TYPE_CONSTANT) {
|
||||
@@ -193,13 +195,12 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar
|
||||
|
||||
// Avoid flakiness
|
||||
e2e().focused().blur();
|
||||
e2e()
|
||||
.contains('.gf-form-group', 'Preview of values')
|
||||
.within(() => {
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption()
|
||||
.should('exist')
|
||||
.within((previewOfValues) => {
|
||||
if (type === VARIABLE_TYPE_CONSTANT) {
|
||||
e2e()
|
||||
.root()
|
||||
.contains(constantValue as string);
|
||||
expect(previewOfValues.text()).equals(constantValue);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AddDataSourceConfig {
|
||||
name: string;
|
||||
skipTlsVerify: boolean;
|
||||
type: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// @todo this actually returns type `Cypress.Chainable<AddDaaSourceConfig>`
|
||||
@@ -40,6 +41,7 @@ export const addDataSource = (config?: Partial<AddDataSourceConfig>) => {
|
||||
name,
|
||||
skipTlsVerify,
|
||||
type,
|
||||
timeout,
|
||||
} = fullConfig;
|
||||
|
||||
e2e().logToConsole('Adding data source with name:', name);
|
||||
@@ -75,8 +77,13 @@ export const addDataSource = (config?: Partial<AddDataSourceConfig>) => {
|
||||
form();
|
||||
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
e2e.pages.DataSource.alert().should('exist').contains(expectedAlertMessage); // assertion
|
||||
|
||||
// use the timeout passed in if it exists, otherwise, continue to use the default
|
||||
e2e.pages.DataSource.alert()
|
||||
.should('exist')
|
||||
.contains(expectedAlertMessage, {
|
||||
timeout: timeout ?? e2e.config().defaultCommandTimeout,
|
||||
});
|
||||
e2e().logToConsole('Added data source with name:', name);
|
||||
|
||||
return e2e()
|
||||
|
||||
@@ -34,6 +34,7 @@ interface ConfigurePanelOptional {
|
||||
panelTitle?: string;
|
||||
timeRange?: TimeRangeConfig;
|
||||
visualizationName?: string;
|
||||
matchExploreTable?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigurePanelRequired {
|
||||
@@ -75,6 +76,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC
|
||||
dataSourceName,
|
||||
isEdit,
|
||||
isExplore,
|
||||
matchExploreTable,
|
||||
matchScreenshot,
|
||||
panelTitle,
|
||||
queriesForm,
|
||||
@@ -200,7 +202,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC
|
||||
let visualization;
|
||||
|
||||
if (isExplore) {
|
||||
visualization = e2e.pages.Explore.General.graph();
|
||||
visualization = matchExploreTable ? e2e.pages.Explore.General.table() : e2e.pages.Explore.General.graph();
|
||||
} else {
|
||||
visualization = e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { e2eScenario, ScenarioArguments } from './support/scenario';
|
||||
import { getScenarioContext, setScenarioContext } from './support/scenarioContext';
|
||||
import { e2eFactory } from './support';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors';
|
||||
import * as flows from './flows';
|
||||
import * as typings from './typings';
|
||||
|
||||
@@ -22,6 +22,7 @@ const e2eObject = {
|
||||
flows,
|
||||
getScenarioContext,
|
||||
setScenarioContext,
|
||||
getSelectors: <T extends Selectors>(selectors: E2ESelectors<T>) => e2eFactory({ selectors }),
|
||||
};
|
||||
|
||||
export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -22,8 +22,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "7.4.0-pre.0",
|
||||
"@grafana/ui": "7.4.0-pre.0",
|
||||
"@grafana/data": "7.4.5",
|
||||
"@grafana/ui": "7.4.5",
|
||||
"systemjs": "0.20.19",
|
||||
"systemjs-plugin-css": "0.1.37"
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
sampleRate: 1,
|
||||
};
|
||||
marketplaceUrl?: string;
|
||||
expressionsEnabled = false;
|
||||
|
||||
constructor(options: GrafanaBootConfig) {
|
||||
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
|
||||
|
||||
@@ -28,12 +28,31 @@ export interface DataSourceSrv {
|
||||
|
||||
/** @public */
|
||||
export interface GetDataSourceListFilters {
|
||||
/** Include mixed deta source by setting this to true */
|
||||
mixed?: boolean;
|
||||
|
||||
/** Only return data sources that support metrics response */
|
||||
metrics?: boolean;
|
||||
|
||||
/** Only return data sources that support tracing response */
|
||||
tracing?: boolean;
|
||||
|
||||
/** Only return data sources that support annotations */
|
||||
annotations?: boolean;
|
||||
|
||||
/**
|
||||
* By default only data sources that can be queried will be returned. Meaning they have tracing,
|
||||
* metrics, logs or annotations flag set in plugin.json file
|
||||
* */
|
||||
all?: boolean;
|
||||
|
||||
/** Set to true to return dashboard data source */
|
||||
dashboard?: boolean;
|
||||
|
||||
/** Set to true to return data source variables */
|
||||
variables?: boolean;
|
||||
|
||||
/** filter list by plugin */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l
|
||||
"b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32"
|
||||
chmod +x /usr/local/bin/cc-test-reporter
|
||||
|
||||
curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.33/grabpl"
|
||||
curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl"
|
||||
|
||||
apk add --no-cache git
|
||||
# Install Mage
|
||||
|
||||
@@ -44,7 +44,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l
|
||||
"b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32"
|
||||
chmod 755 /usr/local/bin/cc-test-reporter
|
||||
|
||||
wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.33/grabpl"
|
||||
wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl"
|
||||
chmod +x /usr/local/bin/grabpl
|
||||
|
||||
# Install Mage
|
||||
|
||||
@@ -27,7 +27,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l
|
||||
"b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32"
|
||||
chmod +x /usr/local/bin/cc-test-reporter
|
||||
|
||||
wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.33/grabpl"
|
||||
wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl"
|
||||
chmod +x /usr/local/bin/grabpl
|
||||
|
||||
# Install Mage
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -28,10 +28,10 @@
|
||||
"dependencies": {
|
||||
"@babel/core": "7.9.0",
|
||||
"@babel/preset-env": "7.9.0",
|
||||
"@grafana/data": "next",
|
||||
"@grafana/data": "7.4.5",
|
||||
"@grafana/eslint-config": "2.1.0",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@grafana/ui": "next",
|
||||
"@grafana/ui": "7.4.5",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/execa": "^0.9.0",
|
||||
"@types/expect-puppeteer": "3.3.1",
|
||||
@@ -56,7 +56,7 @@
|
||||
"command-exists": "^1.2.8",
|
||||
"commander": "^5.0.0",
|
||||
"concurrently": "4.1.0",
|
||||
"copy-webpack-plugin": "5.1.1",
|
||||
"copy-webpack-plugin": "5.1.2",
|
||||
"css-loader": "3.4.2",
|
||||
"eslint": "7.4.0",
|
||||
"eslint-config-prettier": "7.2.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"semver": "^7.1.3",
|
||||
"simple-git": "^1.132.0",
|
||||
"style-loader": "1.1.3",
|
||||
"terser-webpack-plugin": "2.3.5",
|
||||
"terser-webpack-plugin": "2.3.7",
|
||||
"ts-jest": "26.4.4",
|
||||
"ts-loader": "6.2.1",
|
||||
"ts-node": "9.0.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ async function* walk(dir: string, baseDir: string): AsyncGenerator<string, any,
|
||||
} else if (d.isFile()) {
|
||||
yield path.posix.relative(baseDir, entry);
|
||||
} else if (d.isSymbolicLink()) {
|
||||
const realPath = fs.realpathSync(entry);
|
||||
const realPath = await (fs.promises as any).realpath(entry);
|
||||
if (!realPath.startsWith(baseDir)) {
|
||||
throw new Error(
|
||||
`symbolic link ${path.posix.relative(
|
||||
@@ -22,7 +22,11 @@ async function* walk(dir: string, baseDir: string): AsyncGenerator<string, any,
|
||||
)} targets a file outside of the base directory: ${baseDir}`
|
||||
);
|
||||
}
|
||||
yield path.posix.relative(baseDir, entry);
|
||||
// if resolved symlink target is a file include it in the manifest
|
||||
const stats = await (fs.promises as any).stat(realPath);
|
||||
if (stats.isFile()) {
|
||||
yield path.posix.relative(baseDir, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import lightTheme from '../../../public/sass/grafana.light.scss';
|
||||
// @ts-ignore
|
||||
import darkTheme from '../../../public/sass/grafana.dark.scss';
|
||||
import { GrafanaLight, GrafanaDark } from './storybookTheme';
|
||||
import { configure } from '@storybook/react';
|
||||
import addons from '@storybook/addons';
|
||||
|
||||
const handleThemeChange = (theme: any) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "7.4.0-pre.0",
|
||||
"version": "7.4.5",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -27,20 +27,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/core": "^10.0.27",
|
||||
"@grafana/data": "7.4.0-pre.0",
|
||||
"@grafana/e2e-selectors": "7.4.0-pre.0",
|
||||
"@grafana/data": "7.4.5",
|
||||
"@grafana/e2e-selectors": "7.4.5",
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@iconscout/react-unicons": "1.1.4",
|
||||
"@popperjs/core": "2.5.4",
|
||||
"@sentry/browser": "5.25.0",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@torkelo/react-select": "3.0.8",
|
||||
"@types/hoist-non-react-statics": "3.3.1",
|
||||
"@types/react-beautiful-dnd": "12.1.2",
|
||||
"@types/react-color": "3.0.1",
|
||||
"@types/react-select": "3.0.8",
|
||||
"@types/react-table": "7.0.12",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@sentry/browser": "5.25.0",
|
||||
"@types/slate": "0.47.1",
|
||||
"@types/slate-react": "0.22.5",
|
||||
"classnames": "2.2.6",
|
||||
@@ -72,14 +72,14 @@
|
||||
"react-transition-group": "4.4.1",
|
||||
"slate": "0.47.8",
|
||||
"tinycolor2": "1.4.1",
|
||||
"uplot": "1.6.1"
|
||||
"uplot": "1.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "16.0.0",
|
||||
"@rollup/plugin-image": "2.0.5",
|
||||
"@rollup/plugin-node-resolve": "10.0.0",
|
||||
"@storybook/addon-essentials": "6.1.9",
|
||||
"@storybook/addon-controls": "6.1.9",
|
||||
"@storybook/addon-essentials": "6.1.9",
|
||||
"@storybook/addon-knobs": "6.1.9",
|
||||
"@storybook/addon-storysource": "6.1.9",
|
||||
"@storybook/react": "6.1.9",
|
||||
@@ -103,8 +103,8 @@
|
||||
"mock-raf": "1.0.1",
|
||||
"pretty-format": "25.1.0",
|
||||
"react-docgen-typescript-loader": "3.7.2",
|
||||
"react-test-renderer": "16.13.1",
|
||||
"react-is": "16.8.0",
|
||||
"react-test-renderer": "16.13.1",
|
||||
"rollup": "2.33.3",
|
||||
"rollup-plugin-sourcemaps": "0.6.3",
|
||||
"rollup-plugin-terser": "7.0.2",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { toDataFrame, FieldType, VizOrientation } from '@grafana/data';
|
||||
import React from 'react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { BarChart } from './BarChart';
|
||||
import { LegendDisplayMode } from '../VizLegend/types';
|
||||
import { prepDataForStorybook } from '../../utils/storybook/data';
|
||||
import { useTheme } from '../../themes';
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
import { BarChartOptions, BarStackingMode, BarValueVisibility } from './types';
|
||||
|
||||
export default {
|
||||
title: 'Visualizations/BarChart',
|
||||
component: BarChart,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {},
|
||||
},
|
||||
};
|
||||
|
||||
const getKnobs = () => {
|
||||
return {
|
||||
legendPlacement: select(
|
||||
'Legend placement',
|
||||
{
|
||||
bottom: 'bottom',
|
||||
right: 'right',
|
||||
},
|
||||
'bottom'
|
||||
),
|
||||
orientation: select(
|
||||
'Bar orientation',
|
||||
{
|
||||
vertical: VizOrientation.Vertical,
|
||||
horizontal: VizOrientation.Horizontal,
|
||||
},
|
||||
VizOrientation.Vertical
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const Basic: React.FC = () => {
|
||||
const { legendPlacement, orientation } = getKnobs();
|
||||
|
||||
const theme = useTheme();
|
||||
const frame = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'x', type: FieldType.string, values: ['group 1', 'group 2'] },
|
||||
{ name: 'a', type: FieldType.number, values: [10, 20] },
|
||||
{ name: 'b', type: FieldType.number, values: [30, 10] },
|
||||
],
|
||||
});
|
||||
|
||||
const data = prepDataForStorybook([frame], theme);
|
||||
|
||||
const options: BarChartOptions = {
|
||||
orientation: orientation,
|
||||
legend: { displayMode: LegendDisplayMode.List, placement: legendPlacement, calcs: [] },
|
||||
stacking: BarStackingMode.None,
|
||||
showValue: BarValueVisibility.Always,
|
||||
barWidth: 0.97,
|
||||
groupWidth: 0.7,
|
||||
};
|
||||
|
||||
return <BarChart data={data[0]} width={600} height={400} theme={theme} {...options} />;
|
||||
};
|
||||
318
packages/grafana-ui/src/components/BarChart/BarChart.tsx
Normal file
318
packages/grafana-ui/src/components/BarChart/BarChart.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
compareDataFrameStructures,
|
||||
DataFrame,
|
||||
DefaultTimeZone,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
getFieldSeriesColor,
|
||||
getFieldColorModeForField,
|
||||
TimeRange,
|
||||
VizOrientation,
|
||||
fieldReducers,
|
||||
reduceField,
|
||||
DisplayValue,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { Themeable } from '../../types';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config';
|
||||
import { useTheme } from '../../themes';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types';
|
||||
import { FIXED_UNIT } from '../GraphNG/GraphNG';
|
||||
import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
|
||||
import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types';
|
||||
import { BarsOptions, getConfig } from './bars';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface Props extends Themeable, BarChartOptions {
|
||||
height: number;
|
||||
width: number;
|
||||
data: DataFrame;
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
onSeriesColorChange?: (label: string, color: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const BarChart: React.FunctionComponent<Props> = ({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
orientation,
|
||||
groupWidth,
|
||||
barWidth,
|
||||
showValue,
|
||||
legend,
|
||||
onLegendClick,
|
||||
onSeriesColorChange,
|
||||
...plotProps
|
||||
}) => {
|
||||
if (!data || data.fields.length < 2) {
|
||||
return <div>Missing data</div>;
|
||||
}
|
||||
|
||||
// dominik? TODO? can this all be moved into `useRevision`
|
||||
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
|
||||
if (a && b) {
|
||||
return compareDataFrameStructures(a, b);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const configRev = useRevision(data, compareFrames);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Updates only when the structure changes
|
||||
const configBuilder = useMemo(() => {
|
||||
if (!orientation || orientation === VizOrientation.Auto) {
|
||||
orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
|
||||
}
|
||||
|
||||
// bar orientation -> x scale orientation & direction
|
||||
let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection;
|
||||
|
||||
if (orientation === VizOrientation.Vertical) {
|
||||
xOri = ScaleOrientation.Horizontal;
|
||||
xDir = ScaleDirection.Right;
|
||||
yOri = ScaleOrientation.Vertical;
|
||||
yDir = ScaleDirection.Up;
|
||||
} else {
|
||||
xOri = ScaleOrientation.Vertical;
|
||||
xDir = ScaleDirection.Down;
|
||||
yOri = ScaleOrientation.Horizontal;
|
||||
yDir = ScaleDirection.Right;
|
||||
}
|
||||
|
||||
const formatValue =
|
||||
showValue !== BarValueVisibility.Never
|
||||
? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value))
|
||||
: undefined;
|
||||
|
||||
// Use bar width when only one field
|
||||
if (data.fields.length === 2) {
|
||||
groupWidth = barWidth;
|
||||
barWidth = 1;
|
||||
}
|
||||
|
||||
const opts: BarsOptions = {
|
||||
xOri,
|
||||
xDir,
|
||||
groupWidth,
|
||||
barWidth,
|
||||
formatValue,
|
||||
onHover: (seriesIdx: number, valueIdx: number) => {
|
||||
console.log('hover', { seriesIdx, valueIdx });
|
||||
},
|
||||
onLeave: (seriesIdx: number, valueIdx: number) => {
|
||||
console.log('leave', { seriesIdx, valueIdx });
|
||||
},
|
||||
};
|
||||
const config = getConfig(opts);
|
||||
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addHook('init', config.init);
|
||||
builder.addHook('drawClear', config.drawClear);
|
||||
builder.addHook('setCursor', config.setCursor);
|
||||
|
||||
builder.setCursor(config.cursor);
|
||||
builder.setSelect(config.select);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
isTime: false,
|
||||
distribution: ScaleDistribution.Ordinal,
|
||||
orientation: xOri,
|
||||
direction: xDir,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
isTime: false,
|
||||
placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left,
|
||||
splits: config.xSplits,
|
||||
values: config.xValues,
|
||||
grid: false,
|
||||
ticks: false,
|
||||
gap: 15,
|
||||
theme,
|
||||
});
|
||||
|
||||
let seriesIndex = 0;
|
||||
|
||||
// iterate the y values
|
||||
for (let i = 1; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
|
||||
field.state!.seriesIndex = seriesIndex++;
|
||||
|
||||
const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom };
|
||||
|
||||
const scaleKey = field.config.unit || FIXED_UNIT;
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const scaleColor = getFieldSeriesColor(field, theme);
|
||||
const seriesColor = scaleColor.color;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey,
|
||||
pxAlign: false,
|
||||
lineWidth: customConfig.lineWidth,
|
||||
lineColor: seriesColor,
|
||||
//lineStyle: customConfig.lineStyle,
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
theme,
|
||||
colorMode,
|
||||
pathBuilder: config.drawBars,
|
||||
pointsBuilder: config.drawPoints,
|
||||
show: !customConfig.hideFrom?.graph,
|
||||
gradientMode: customConfig.gradientMode,
|
||||
thresholds: field.config.thresholds,
|
||||
|
||||
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
|
||||
dataFrameFieldIndex: {
|
||||
fieldIndex: i,
|
||||
frameIndex: 0,
|
||||
},
|
||||
fieldName: getFieldDisplayName(field, data),
|
||||
hideInLegend: customConfig.hideFrom?.legend,
|
||||
});
|
||||
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
builder.addScale({
|
||||
scaleKey,
|
||||
min: field.config.min,
|
||||
max: field.config.max,
|
||||
softMin: customConfig.axisSoftMin,
|
||||
softMax: customConfig.axisSoftMax,
|
||||
orientation: yOri,
|
||||
direction: yDir,
|
||||
});
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
let placement = customConfig.axisPlacement;
|
||||
if (!placement || placement === AxisPlacement.Auto) {
|
||||
placement = AxisPlacement.Left;
|
||||
}
|
||||
if (xOri === 1) {
|
||||
if (placement === AxisPlacement.Left) {
|
||||
placement = AxisPlacement.Bottom;
|
||||
}
|
||||
if (placement === AxisPlacement.Right) {
|
||||
placement = AxisPlacement.Top;
|
||||
}
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey,
|
||||
label: customConfig.axisLabel,
|
||||
size: customConfig.axisWidth,
|
||||
placement,
|
||||
formatValue: (v) => formattedValueToString(field.display!(v)),
|
||||
theme,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}, [data, configRev, orientation, width, height]);
|
||||
|
||||
const onLabelClick = useCallback(
|
||||
(legend: VizLegendItem, event: React.MouseEvent) => {
|
||||
const { fieldIndex } = legend;
|
||||
|
||||
if (!onLegendClick || !fieldIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLegendClick({
|
||||
fieldIndex,
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
});
|
||||
},
|
||||
[onLegendClick, data]
|
||||
);
|
||||
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
|
||||
const legendItems = configBuilder
|
||||
.getSeries()
|
||||
.map<VizLegendItem | undefined>((s) => {
|
||||
const seriesConfig = s.props;
|
||||
const fieldIndex = seriesConfig.dataFrameFieldIndex;
|
||||
if (seriesConfig.hideInLegend || !fieldIndex) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const field = data.fields[fieldIndex.fieldIndex];
|
||||
if (!field) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: !seriesConfig.show ?? false,
|
||||
fieldIndex,
|
||||
color: seriesConfig.lineColor!,
|
||||
label: seriesConfig.fieldName,
|
||||
yAxis: 1,
|
||||
getDisplayValues: () => {
|
||||
if (!legend.calcs?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fieldCalcs = reduceField({
|
||||
field,
|
||||
reducers: legend.calcs,
|
||||
});
|
||||
|
||||
return legend.calcs.map<DisplayValue>((reducer) => {
|
||||
return {
|
||||
...field.display!(fieldCalcs[reducer]),
|
||||
title: fieldReducers.get(reducer).name,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((i) => i !== undefined) as VizLegendItem[];
|
||||
|
||||
let legendElement: React.ReactElement | undefined;
|
||||
|
||||
if (hasLegend && legendItems.length > 0) {
|
||||
legendElement = (
|
||||
<VizLayout.Legend position={legend.placement} maxHeight="35%" maxWidth="60%">
|
||||
<VizLegend
|
||||
onLabelClick={onLabelClick}
|
||||
placement={legend.placement}
|
||||
items={legendItems}
|
||||
displayMode={legend.displayMode}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
/>
|
||||
</VizLayout.Legend>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height} legend={legendElement}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={data}
|
||||
config={configBuilder}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
|
||||
timeZone={DefaultTimeZone}
|
||||
/>
|
||||
)}
|
||||
</VizLayout>
|
||||
);
|
||||
};
|
||||
294
packages/grafana-ui/src/components/BarChart/bars.ts
Normal file
294
packages/grafana-ui/src/components/BarChart/bars.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import uPlot, { Axis, Series, Cursor, BBox } from 'uplot';
|
||||
import { Quadtree, Rect, pointWithin } from './quadtree';
|
||||
import { distribute, SPACE_BETWEEN } from './distribute';
|
||||
|
||||
const pxRatio = devicePixelRatio;
|
||||
|
||||
const groupDistr = SPACE_BETWEEN;
|
||||
const barDistr = SPACE_BETWEEN;
|
||||
|
||||
const font = Math.round(10 * pxRatio) + 'px Arial';
|
||||
|
||||
type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void);
|
||||
|
||||
function walkTwo(
|
||||
groupWidth: number,
|
||||
barWidth: number,
|
||||
yIdx: number,
|
||||
xCount: number,
|
||||
yCount: number,
|
||||
xDim: number,
|
||||
xDraw?: WalkTwoCb,
|
||||
yDraw?: WalkTwoCb
|
||||
) {
|
||||
distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => {
|
||||
let groupOffPx = xDim * offPct;
|
||||
let groupWidPx = xDim * dimPct;
|
||||
|
||||
xDraw && xDraw(ix, groupOffPx, groupWidPx);
|
||||
|
||||
yDraw &&
|
||||
distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => {
|
||||
let barOffPx = groupWidPx * offPct;
|
||||
let barWidPx = groupWidPx * dimPct;
|
||||
|
||||
yDraw(ix, groupOffPx + barOffPx, barWidPx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface BarsOptions {
|
||||
xOri: 1 | 0;
|
||||
xDir: 1 | -1;
|
||||
groupWidth: number;
|
||||
barWidth: number;
|
||||
formatValue?: (seriesIdx: number, value: any) => string;
|
||||
onHover?: (seriesIdx: number, valueIdx: any) => void;
|
||||
onLeave?: (seriesIdx: number, valueIdx: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getConfig(opts: BarsOptions) {
|
||||
const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, onHover, onLeave } = opts;
|
||||
|
||||
let qt: Quadtree;
|
||||
|
||||
const drawBars: Series.PathBuilder = (u, sidx, i0, i1) => {
|
||||
return uPlot.orient(
|
||||
u,
|
||||
sidx,
|
||||
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
|
||||
const fill = new Path2D();
|
||||
const stroke = new Path2D();
|
||||
|
||||
let numGroups = dataX.length;
|
||||
let barsPerGroup = u.series.length - 1;
|
||||
|
||||
let y0Pos = valToPosY(0, scaleY, yDim, yOff);
|
||||
|
||||
const _dir = dir * (ori === 0 ? 1 : -1);
|
||||
|
||||
walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => {
|
||||
let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid));
|
||||
let barWid = Math.round(wid);
|
||||
|
||||
if (dataY[ix] != null) {
|
||||
let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff);
|
||||
|
||||
let btm = Math.round(Math.max(yPos, y0Pos));
|
||||
let top = Math.round(Math.min(yPos, y0Pos));
|
||||
let barHgt = btm - top;
|
||||
|
||||
let strokeWidth = series.width || 0;
|
||||
|
||||
if (strokeWidth) {
|
||||
rect(stroke, lft + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth);
|
||||
}
|
||||
|
||||
rect(fill, lft, top, barWid, barHgt);
|
||||
|
||||
let x = ori === 0 ? Math.round(lft - xOff) : Math.round(top - yOff);
|
||||
let y = ori === 0 ? Math.round(top - yOff) : Math.round(lft - xOff);
|
||||
let w = ori === 0 ? barWid : barHgt;
|
||||
let h = ori === 0 ? barHgt : barWid;
|
||||
|
||||
qt.add({ x, y, w, h, sidx: sidx, didx: ix });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stroke,
|
||||
fill,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const drawPoints: Series.Points.Show =
|
||||
formatValue == null
|
||||
? false
|
||||
: (u, sidx, i0, i1) => {
|
||||
u.ctx.font = font;
|
||||
u.ctx.fillStyle = 'white';
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
sidx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect
|
||||
) => {
|
||||
let numGroups = dataX.length;
|
||||
let barsPerGroup = u.series.length - 1;
|
||||
|
||||
const _dir = dir * (ori === 0 ? 1 : -1);
|
||||
|
||||
walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => {
|
||||
let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid));
|
||||
let barWid = Math.round(wid);
|
||||
|
||||
// prettier-ignore
|
||||
if (dataY[ix] != null) {
|
||||
let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff);
|
||||
|
||||
/* eslint-disable no-multi-spaces */
|
||||
let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos);
|
||||
let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2);
|
||||
|
||||
u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right';
|
||||
u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top';
|
||||
/* eslint-enable */
|
||||
|
||||
u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/*
|
||||
const yRange: Scale.Range = (u, dataMin, dataMax) => {
|
||||
// @ts-ignore
|
||||
let [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true);
|
||||
return [0, max];
|
||||
};
|
||||
*/
|
||||
|
||||
const xSplits: Axis.Splits = (u: uPlot, axisIdx: number) => {
|
||||
const dim = ori === 0 ? u.bbox.width : u.bbox.height;
|
||||
const _dir = dir * (ori === 0 ? 1 : -1);
|
||||
|
||||
let splits: number[] = [];
|
||||
|
||||
distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => {
|
||||
let groupLftPx = (dim * lftPct) / pxRatio;
|
||||
let groupWidPx = (dim * widPct) / pxRatio;
|
||||
|
||||
let groupCenterPx = groupLftPx + groupWidPx / 2;
|
||||
|
||||
splits.push(u.posToVal(groupCenterPx, 'x'));
|
||||
});
|
||||
|
||||
return _dir === 1 ? splits : splits.reverse();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const xValues: Axis.Values = (u) => u.data[0];
|
||||
|
||||
let hovered: Rect | null = null;
|
||||
|
||||
let barMark = document.createElement('div');
|
||||
barMark.classList.add('bar-mark');
|
||||
barMark.style.position = 'absolute';
|
||||
barMark.style.background = 'rgba(255,255,255,0.4)';
|
||||
|
||||
// hide crosshair cursor & hover points
|
||||
const cursor: Cursor = {
|
||||
x: false,
|
||||
y: false,
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
|
||||
// disable selection
|
||||
// uPlot types do not export the Select interface prior to 1.6.4
|
||||
const select: Partial<BBox> = {
|
||||
show: false,
|
||||
};
|
||||
|
||||
const init = (u: uPlot) => {
|
||||
let over = u.root.querySelector('.u-over')! as HTMLElement;
|
||||
over.style.overflow = 'hidden';
|
||||
over.appendChild(barMark);
|
||||
};
|
||||
|
||||
const drawClear = (u: uPlot) => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
qt.clear();
|
||||
|
||||
// clear the path cache to force drawBars() to rebuild new quadtree
|
||||
u.series.forEach((s) => {
|
||||
// @ts-ignore
|
||||
s._paths = null;
|
||||
});
|
||||
};
|
||||
|
||||
// handle hover interaction with quadtree probing
|
||||
const setCursor = (u: uPlot) => {
|
||||
let found: Rect | null = null;
|
||||
let cx = u.cursor.left! * pxRatio;
|
||||
let cy = u.cursor.top! * pxRatio;
|
||||
|
||||
qt.get(cx, cy, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
found = o;
|
||||
}
|
||||
});
|
||||
|
||||
if (found) {
|
||||
// prettier-ignore
|
||||
if (found !== hovered) {
|
||||
/* eslint-disable no-multi-spaces */
|
||||
barMark.style.display = '';
|
||||
barMark.style.left = found!.x / pxRatio + 'px';
|
||||
barMark.style.top = found!.y / pxRatio + 'px';
|
||||
barMark.style.width = found!.w / pxRatio + 'px';
|
||||
barMark.style.height = found!.h / pxRatio + 'px';
|
||||
hovered = found;
|
||||
/* eslint-enable */
|
||||
|
||||
if (onHover != null) {
|
||||
onHover(hovered!.sidx, hovered!.didx);
|
||||
}
|
||||
}
|
||||
} else if (hovered != null) {
|
||||
if (onLeave != null) {
|
||||
onLeave(hovered!.sidx, hovered!.didx);
|
||||
}
|
||||
|
||||
hovered = null;
|
||||
barMark.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// cursor & select opts
|
||||
cursor,
|
||||
select,
|
||||
|
||||
// scale & axis opts
|
||||
// yRange,
|
||||
xValues,
|
||||
xSplits,
|
||||
|
||||
// pathbuilders
|
||||
drawBars,
|
||||
drawPoints,
|
||||
|
||||
// hooks
|
||||
init,
|
||||
drawClear,
|
||||
setCursor,
|
||||
};
|
||||
}
|
||||
49
packages/grafana-ui/src/components/BarChart/distribute.ts
Normal file
49
packages/grafana-ui/src/components/BarChart/distribute.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
function roundDec(val: number, dec: number) {
|
||||
return Math.round(val * (dec = 10 ** dec)) / dec;
|
||||
}
|
||||
|
||||
export const SPACE_BETWEEN = 1;
|
||||
export const SPACE_AROUND = 2;
|
||||
export const SPACE_EVENLY = 3;
|
||||
|
||||
const coord = (i: number, offs: number, iwid: number, gap: number) => roundDec(offs + i * (iwid + gap), 6);
|
||||
|
||||
export type Each = (idx: number, offPct: number, dimPct: number) => void;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function distribute(numItems: number, sizeFactor: number, justify: number, onlyIdx: number | null, each: Each) {
|
||||
let space = 1 - sizeFactor;
|
||||
|
||||
/* eslint-disable no-multi-spaces */
|
||||
// prettier-ignore
|
||||
let gap = (
|
||||
justify === SPACE_BETWEEN ? space / (numItems - 1) :
|
||||
justify === SPACE_AROUND ? space / (numItems ) :
|
||||
justify === SPACE_EVENLY ? space / (numItems + 1) : 0
|
||||
);
|
||||
|
||||
if (isNaN(gap) || gap === Infinity) {
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
let offs = (
|
||||
justify === SPACE_BETWEEN ? 0 :
|
||||
justify === SPACE_AROUND ? gap / 2 :
|
||||
justify === SPACE_EVENLY ? gap : 0
|
||||
);
|
||||
/* eslint-enable */
|
||||
|
||||
let iwid = sizeFactor / numItems;
|
||||
let _iwid = roundDec(iwid, 6);
|
||||
|
||||
if (onlyIdx == null) {
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
each(i, coord(i, offs, iwid, gap), _iwid);
|
||||
}
|
||||
} else {
|
||||
each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid);
|
||||
}
|
||||
}
|
||||
113
packages/grafana-ui/src/components/BarChart/quadtree.ts
Normal file
113
packages/grafana-ui/src/components/BarChart/quadtree.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
export type Quads = [Quadtree, Quadtree, Quadtree, Quadtree];
|
||||
export type Rect = { x: number; y: number; w: number; h: number; [_: string]: any };
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function pointWithin(px: number, py: number, rlft: number, rtop: number, rrgt: number, rbtm: number) {
|
||||
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export class Quadtree {
|
||||
o: Rect[];
|
||||
q: Quads | null;
|
||||
constructor(public x: number, public y: number, public w: number, public h: number, public l: number = 0) {
|
||||
this.o = [];
|
||||
this.q = null;
|
||||
}
|
||||
|
||||
split() {
|
||||
let t = this,
|
||||
x = t.x,
|
||||
y = t.y,
|
||||
w = t.w / 2,
|
||||
h = t.h / 2,
|
||||
l = t.l + 1;
|
||||
|
||||
t.q = [
|
||||
// top right
|
||||
new Quadtree(x + w, y, w, h, l),
|
||||
// top left
|
||||
new Quadtree(x, y, w, h, l),
|
||||
// bottom left
|
||||
new Quadtree(x, y + h, w, h, l),
|
||||
// bottom right
|
||||
new Quadtree(x + w, y + h, w, h, l),
|
||||
];
|
||||
}
|
||||
|
||||
// invokes callback with index of each overlapping quad
|
||||
quads(x: number, y: number, w: number, h: number, cb: (q: Quadtree) => void) {
|
||||
let t = this,
|
||||
q = t.q!,
|
||||
hzMid = t.x + t.w / 2,
|
||||
vtMid = t.y + t.h / 2,
|
||||
startIsNorth = y < vtMid,
|
||||
startIsWest = x < hzMid,
|
||||
endIsEast = x + w > hzMid,
|
||||
endIsSouth = y + h > vtMid;
|
||||
|
||||
// top-right quad
|
||||
startIsNorth && endIsEast && cb(q[0]);
|
||||
// top-left quad
|
||||
startIsWest && startIsNorth && cb(q[1]);
|
||||
// bottom-left quad
|
||||
startIsWest && endIsSouth && cb(q[2]);
|
||||
// bottom-right quad
|
||||
endIsEast && endIsSouth && cb(q[3]);
|
||||
}
|
||||
|
||||
add(o: Rect) {
|
||||
let t = this;
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(o.x, o.y, o.w, o.h, (q) => {
|
||||
q.add(o);
|
||||
});
|
||||
} else {
|
||||
let os = t.o;
|
||||
|
||||
os.push(o);
|
||||
|
||||
if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) {
|
||||
t.split();
|
||||
|
||||
for (let i = 0; i < os.length; i++) {
|
||||
let oi = os[i];
|
||||
|
||||
t.quads(oi.x, oi.y, oi.w, oi.h, (q) => {
|
||||
q.add(oi);
|
||||
});
|
||||
}
|
||||
|
||||
t.o.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(x: number, y: number, w: number, h: number, cb: (o: Rect) => void) {
|
||||
let t = this;
|
||||
let os = t.o;
|
||||
|
||||
for (let i = 0; i < os.length; i++) {
|
||||
cb(os[i]);
|
||||
}
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(x, y, w, h, (q) => {
|
||||
q.get(x, y, w, h, cb);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.o.length = 0;
|
||||
this.q = null;
|
||||
}
|
||||
}
|
||||
52
packages/grafana-ui/src/components/BarChart/types.ts
Normal file
52
packages/grafana-ui/src/components/BarChart/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { VizOrientation } from '@grafana/data';
|
||||
import { AxisConfig, GraphGradientMode, HideableFieldConfig } from '../uPlot/config';
|
||||
import { VizLegendOptions } from '../VizLegend/types';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export enum BarStackingMode {
|
||||
None = 'none',
|
||||
Standard = 'standard',
|
||||
Percent = 'percent',
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export enum BarValueVisibility {
|
||||
Auto = 'auto',
|
||||
Never = 'never',
|
||||
Always = 'always',
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface BarChartOptions {
|
||||
orientation: VizOrientation;
|
||||
legend: VizLegendOptions;
|
||||
stacking: BarStackingMode;
|
||||
showValue: BarValueVisibility;
|
||||
barWidth: number;
|
||||
groupWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface BarChartFieldConfig extends AxisConfig, HideableFieldConfig {
|
||||
lineWidth?: number; // 0
|
||||
fillOpacity?: number; // 100
|
||||
gradientMode?: GraphGradientMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const defaultBarChartFieldConfig: BarChartFieldConfig = {
|
||||
lineWidth: 1,
|
||||
fillOpacity: 80,
|
||||
gradientMode: GraphGradientMode.None,
|
||||
axisSoftMin: 0,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getTitleStyles,
|
||||
getValuePercent,
|
||||
BarGaugeDisplayMode,
|
||||
calculateBarAndValueDimensions,
|
||||
} from './BarGauge';
|
||||
import { getTheme } from '../../themes';
|
||||
|
||||
@@ -211,4 +212,18 @@ describe('BarGauge', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBarAndValueDimensions', () => {
|
||||
it('valueWidth should including paddings in valueWidth', () => {
|
||||
const result = calculateBarAndValueDimensions(
|
||||
getProps({
|
||||
height: 30,
|
||||
width: 100,
|
||||
value: getValue(1, 'AA'),
|
||||
orientation: VizOrientation.Horizontal,
|
||||
})
|
||||
);
|
||||
expect(result.valueWidth).toBe(21);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Themeable } from '../../types';
|
||||
|
||||
const MIN_VALUE_HEIGHT = 18;
|
||||
const MAX_VALUE_HEIGHT = 50;
|
||||
const MIN_VALUE_WIDTH = 50;
|
||||
const MAX_VALUE_WIDTH = 150;
|
||||
const TITLE_LINE_HEIGHT = 1.5;
|
||||
const VALUE_LINE_HEIGHT = 1;
|
||||
@@ -373,9 +372,15 @@ interface BarAndValueDimensions {
|
||||
wrapperWidth: number;
|
||||
}
|
||||
|
||||
function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
|
||||
const { height, width, orientation, text } = props;
|
||||
/**
|
||||
* @internal
|
||||
* Only exported for unit tests
|
||||
**/
|
||||
export function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
|
||||
const { height, width, orientation, text, alignmentFactors } = props;
|
||||
const titleDim = calculateTitleDimensions(props);
|
||||
const value = alignmentFactors ?? props.value;
|
||||
const valueString = formattedValueToString(value);
|
||||
|
||||
let maxBarHeight = 0;
|
||||
let maxBarWidth = 0;
|
||||
@@ -384,25 +389,27 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
|
||||
let wrapperWidth = 0;
|
||||
let wrapperHeight = 0;
|
||||
|
||||
// measure text with title font size or min 14px
|
||||
const fontSizeToMeasureWith = text?.valueSize ?? Math.max(titleDim.fontSize, 12);
|
||||
const realTextSize = measureText(valueString, fontSizeToMeasureWith);
|
||||
const realValueWidth = realTextSize.width + VALUE_LEFT_PADDING * 2;
|
||||
|
||||
if (isVertical(orientation)) {
|
||||
if (text?.valueSize) {
|
||||
valueHeight = text.valueSize * VALUE_LINE_HEIGHT;
|
||||
} else {
|
||||
valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT);
|
||||
}
|
||||
|
||||
valueWidth = width;
|
||||
maxBarHeight = height - (titleDim.height + valueHeight);
|
||||
maxBarWidth = width;
|
||||
wrapperWidth = width;
|
||||
wrapperHeight = height - titleDim.height;
|
||||
} else {
|
||||
if (text?.valueSize) {
|
||||
valueHeight = text.valueSize * VALUE_LINE_HEIGHT;
|
||||
} else {
|
||||
valueHeight = height - titleDim.height;
|
||||
}
|
||||
valueHeight = height - titleDim.height;
|
||||
valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), realValueWidth);
|
||||
|
||||
valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), MIN_VALUE_WIDTH);
|
||||
maxBarHeight = height - titleDim.height;
|
||||
maxBarWidth = width - valueWidth - titleDim.width;
|
||||
|
||||
@@ -479,7 +486,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`;
|
||||
|
||||
barStyles.borderTop = `2px solid ${valueColor}`;
|
||||
} else {
|
||||
// Gradient styles
|
||||
@@ -499,6 +505,7 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
|
||||
// shift empty region back to fill gaps due to border radius
|
||||
emptyBar.left = '-3px';
|
||||
emptyBar.width = `${maxBarWidth - barWidth}px`;
|
||||
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
|
||||
@@ -62,6 +62,7 @@ exports[`BarGauge Render with basic options should render 1`] = `
|
||||
"flexGrow": 1,
|
||||
"left": "-3px",
|
||||
"position": "relative",
|
||||
"width": "180px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { boolean } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Story } from '@storybook/react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { Card } from './Card';
|
||||
import { Card, Props } from './Card';
|
||||
import mdx from './Card.mdx';
|
||||
import { Button } from '../Button';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
@@ -18,17 +18,20 @@ export default {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
knobs: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
heading: { control: { disable: true } },
|
||||
description: { control: { disable: true } },
|
||||
href: { control: { disable: true } },
|
||||
tooltip: { control: { disable: true } },
|
||||
onClick: { control: { disable: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const getKnobs = () => {
|
||||
const disabled = boolean('Disabled', false, 'Style props');
|
||||
|
||||
return { disabled };
|
||||
};
|
||||
|
||||
export const Basic = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const Basic: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card
|
||||
heading="Filter by name"
|
||||
@@ -38,8 +41,7 @@ export const Basic = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const AsLink = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const AsLink: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card
|
||||
href="https://grafana.com"
|
||||
@@ -50,8 +52,7 @@ export const AsLink = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const WithTooltip = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const WithTooltip: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card
|
||||
heading="Reduce"
|
||||
@@ -62,8 +63,7 @@ export const WithTooltip = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const WithTags = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const WithTags: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card heading="Elasticsearch – Custom Templated Query" disabled={disabled}>
|
||||
<Card.Meta>Elastic Search</Card.Meta>
|
||||
@@ -74,8 +74,7 @@ export const WithTags = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const WithMedia = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const WithMedia: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card href="https://ops-us-east4.grafana.net/api/prom" heading="1-ops-tools1-fallback" disabled={disabled}>
|
||||
<Card.Meta>
|
||||
@@ -90,8 +89,7 @@ export const WithMedia = () => {
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export const WithActions = () => {
|
||||
const { disabled } = getKnobs();
|
||||
export const WithActions: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card heading="1-ops-tools1-fallback" disabled={disabled}>
|
||||
<Card.Meta>
|
||||
@@ -119,9 +117,7 @@ export const WithActions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Full = () => {
|
||||
const { disabled } = getKnobs();
|
||||
|
||||
export const Full: Story<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<Card
|
||||
heading="Card title"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Field, LinkModel } from '@grafana/data';
|
||||
import React from 'react';
|
||||
import { Button } from '..';
|
||||
import { ButtonProps, Button } from '../Button';
|
||||
|
||||
type FieldLinkProps = {
|
||||
type DataLinkButtonProps = {
|
||||
link: LinkModel<Field>;
|
||||
buttonProps?: ButtonProps;
|
||||
};
|
||||
|
||||
export function FieldLink({ link }: FieldLinkProps) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function DataLinkButton({ link, buttonProps }: DataLinkButtonProps) {
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
target={link.target}
|
||||
rel="noreferrer"
|
||||
onClick={
|
||||
link.onClick
|
||||
@@ -23,7 +27,9 @@ export function FieldLink({ link }: FieldLinkProps) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button icon="external-link-alt">{link.title}</Button>
|
||||
<Button icon="external-link-alt" variant="primary" size="sm" {...buttonProps}>
|
||||
{link.title}
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useMemo, useContext, useRef, RefObject, memo, useEffect } from 'react';
|
||||
import React, { memo, RefObject, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { DataLinkSuggestions } from './DataLinkSuggestions';
|
||||
import { ThemeContext, makeValue } from '../../index';
|
||||
import { makeValue, ThemeContext } from '../../index';
|
||||
import { SelectionReference } from './SelectionReference';
|
||||
import { Portal, getFormStyles } from '../index';
|
||||
import { getFormStyles, Portal } from '../index';
|
||||
|
||||
// @ts-ignore
|
||||
import Prism, { Grammar, LanguageMap } from 'prismjs';
|
||||
@@ -16,7 +16,7 @@ import { css, cx } from 'emotion';
|
||||
import { SlatePrism } from '../../slate-plugins';
|
||||
import { SCHEMA } from '../../utils/slate';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { GrafanaTheme, VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data';
|
||||
import { DataLinkBuiltInVars, GrafanaTheme, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||
|
||||
@@ -130,7 +130,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
|
||||
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
|
||||
editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
|
||||
} else {
|
||||
editor.insertText(`var-${item.value}=$\{${item.value}}`);
|
||||
editor.insertText(`\${${item.value}:queryparam}`);
|
||||
}
|
||||
|
||||
setLinkUrl(editor.value);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DataLinksListItem, DataLinksListItemProps } from './DataLinksListItem';
|
||||
|
||||
const baseLink = {
|
||||
url: '',
|
||||
title: '',
|
||||
onBuildUrl: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
||||
const defaults: DataLinksListItemProps = {
|
||||
index: 0,
|
||||
link: baseLink,
|
||||
data: [],
|
||||
onChange: jest.fn(),
|
||||
onEdit: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const props = { ...defaults, ...options };
|
||||
const { rerender } = render(<DataLinksListItem {...props} />);
|
||||
|
||||
return { rerender, props };
|
||||
}
|
||||
|
||||
describe('DataLinksListItem', () => {
|
||||
describe('when link has title', () => {
|
||||
it('then the link title should be visible', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
title: 'Some Data Link Title',
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/some data link title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when link has url', () => {
|
||||
it('then the link url should be visible', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
url: 'http://localhost:3000',
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when link is missing title', () => {
|
||||
it('then the link title should be replaced by [Data link title not provided]', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
title: (undefined as unknown) as string,
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when link is missing url', () => {
|
||||
it('then the link url should be replaced by [Data link url not provided]', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
url: (undefined as unknown) as string,
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
|
||||
expect(screen.getByTitle('')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when link title is empty', () => {
|
||||
it('then the link title should be replaced by [Data link title not provided]', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
title: ' ',
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when link url is empty', () => {
|
||||
it('then the link url should be replaced by [Data link url not provided]', () => {
|
||||
const link = {
|
||||
...baseLink,
|
||||
url: ' ',
|
||||
};
|
||||
setupTestContext({ link });
|
||||
|
||||
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
|
||||
expect(screen.getByTitle('')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { stylesFactory, useTheme } from '../../../themes';
|
||||
import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout';
|
||||
import { IconButton } from '../../IconButton/IconButton';
|
||||
|
||||
interface DataLinksListItemProps {
|
||||
export interface DataLinksListItemProps {
|
||||
index: number;
|
||||
link: DataLink;
|
||||
data: DataFrame[];
|
||||
@@ -19,24 +19,25 @@ interface DataLinksListItemProps {
|
||||
export const DataLinksListItem: FC<DataLinksListItemProps> = ({ link, onEdit, onRemove }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getDataLinkListItemStyles(theme);
|
||||
const { title = '', url = '' } = link;
|
||||
|
||||
const hasTitle = link.title.trim() !== '';
|
||||
const hasUrl = link.url.trim() !== '';
|
||||
const hasTitle = title.trim() !== '';
|
||||
const hasUrl = url.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<VerticalGroup spacing="xs">
|
||||
<HorizontalGroup justify="space-between" align="flex-start" width="100%">
|
||||
<div className={cx(styles.title, !hasTitle && styles.notConfigured)}>
|
||||
{hasTitle ? link.title : 'Data link title not provided'}
|
||||
{hasTitle ? title : 'Data link title not provided'}
|
||||
</div>
|
||||
<HorizontalGroup>
|
||||
<IconButton name="pen" onClick={onEdit} />
|
||||
<IconButton name="times" onClick={onRemove} />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={link.url}>
|
||||
{hasUrl ? link.url : 'Data link url not provided'}
|
||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={url}>
|
||||
{hasUrl ? url : 'Data link url not provided'}
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Field, GrafanaTheme, LinkModel } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import React from 'react';
|
||||
import { useStyles } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { DataLinkButton } from './DataLinkButton';
|
||||
|
||||
type Props = {
|
||||
links: Array<LinkModel<Field>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function FieldLinkList({ links }: Props) {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
if (links.length === 1) {
|
||||
return <DataLinkButton link={links[0]} />;
|
||||
}
|
||||
|
||||
const externalLinks = links.filter((link) => link.target === '_blank');
|
||||
const internalLinks = links.filter((link) => link.target === '_self');
|
||||
|
||||
return (
|
||||
<>
|
||||
{internalLinks.map((link, i) => {
|
||||
return <DataLinkButton key={i} link={link} />;
|
||||
})}
|
||||
<div className={styles.wrapper}>
|
||||
<p className={styles.externalLinksHeading}>External links</p>
|
||||
{externalLinks.map((link, i) => (
|
||||
<a key={i} href={link.href} target={link.target} className={styles.externalLink}>
|
||||
<Icon name="external-link-alt" />
|
||||
{link.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
wrapper: css`
|
||||
flex-basis: 150px;
|
||||
width: 100px;
|
||||
margin-top: ${theme.spacing.sm};
|
||||
`,
|
||||
externalLinksHeading: css`
|
||||
color: ${theme.colors.textWeak};
|
||||
font-weight: ${theme.typography.weight.regular};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
margin: 0;
|
||||
`,
|
||||
externalLink: css`
|
||||
color: ${theme.colors.linkExternal};
|
||||
font-weight: ${theme.typography.weight.regular};
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div {
|
||||
margin-right: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ArgsTable } from "@storybook/addon-docs/blocks";
|
||||
import { ErrorBoundary, ErrorBoundaryAlert } from "./ErrorBoundary";
|
||||
import { ErrorWithStack } from "./ErrorWithStack";
|
||||
|
||||
# ErrorBoundary
|
||||
|
||||
A React component that catches errors in child components. Useful for logging or displaying a fallback UI in case of errors. More information about error boundaries is available at [React documentation website](https://reactjs.org/docs/error-boundaries.html).
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { ErrorBoundary, Alert } from '@grafana/ui';
|
||||
|
||||
<ErrorBoundary>
|
||||
{({ error }) => {
|
||||
if (error) {
|
||||
return <Alert title={error.message} />;
|
||||
}
|
||||
return <Component />;
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
# ErrorBoundaryAlert
|
||||
|
||||
An error boundary component with built-in alert to display in case of error.
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { ErrorBoundaryAlert } from '@grafana/ui';
|
||||
|
||||
<ErrorBoundaryAlert>
|
||||
<Component />
|
||||
</ErrorBoundaryAlert>
|
||||
```
|
||||
|
||||
### Props
|
||||
<ArgsTable of={ErrorBoundaryAlert}/>
|
||||
|
||||
|
||||
# ErrorWithStack
|
||||
A component that displays error together with its stack trace.
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { ErrorWithStack } from '@grafana/ui';
|
||||
|
||||
<ErrorWithStack error={new Error('Test error')} title={'Unexpected error'} errorInfo={null} />
|
||||
```
|
||||
|
||||
### Props
|
||||
<ArgsTable of={ErrorWithStack}/>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary';
|
||||
import { withCenteredStory } from '@grafana/ui/src/utils/storybook/withCenteredStory';
|
||||
import mdx from './ErrorBoundary.mdx';
|
||||
import { Button } from '../Button';
|
||||
import { ErrorWithStack } from './ErrorWithStack';
|
||||
import { Alert } from '../Alert/Alert';
|
||||
|
||||
export default {
|
||||
title: 'General/ErrorBoundary',
|
||||
component: ErrorBoundary,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const BuggyComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
if (count > 2) {
|
||||
throw new Error('Crashed');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Increase the count to 3 to trigger error</p>
|
||||
<Button onClick={() => setCount(count + 1)}>{count.toString()}</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Basic = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{({ error }) => {
|
||||
if (error) {
|
||||
return <Alert title={error.message} />;
|
||||
}
|
||||
return <BuggyComponent />;
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithStack = () => {
|
||||
return <ErrorWithStack error={new Error('Test error')} title={'Unexpected error'} errorInfo={null} />;
|
||||
};
|
||||
|
||||
export const BoundaryAlert = () => {
|
||||
return (
|
||||
<ErrorBoundaryAlert>
|
||||
<BuggyComponent />
|
||||
</ErrorBoundaryAlert>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@ export interface Props extends Omit<FieldProps, 'css' | 'horizontal' | 'descript
|
||||
labelWidth?: number | 'auto';
|
||||
/** Make the field's child to fill the width of the row. Equivalent to setting `flex-grow:1` on the field */
|
||||
grow?: boolean;
|
||||
/** Make field's background transparent */
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export const InlineField: FC<Props> = ({
|
||||
@@ -25,6 +27,7 @@ export const InlineField: FC<Props> = ({
|
||||
disabled,
|
||||
className,
|
||||
grow,
|
||||
transparent,
|
||||
...htmlProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
@@ -37,7 +40,7 @@ export const InlineField: FC<Props> = ({
|
||||
}
|
||||
const labelElement =
|
||||
typeof label === 'string' ? (
|
||||
<InlineLabel width={labelWidth} tooltip={tooltip} htmlFor={inputId}>
|
||||
<InlineLabel width={labelWidth} tooltip={tooltip} htmlFor={inputId} transparent={transparent}>
|
||||
{label}
|
||||
</InlineLabel>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Props extends Omit<LabelProps, 'css' | 'description' | 'categor
|
||||
tooltip?: PopoverContent;
|
||||
/** Custom width for the label */
|
||||
width?: number | 'auto';
|
||||
/** Make labels's background transparent */
|
||||
transparent?: boolean;
|
||||
/** @deprecated */
|
||||
/** This prop is deprecated and is not used anymore */
|
||||
isFocused?: boolean;
|
||||
@@ -28,12 +30,12 @@ export const InlineLabel: FunctionComponent<Props> = ({
|
||||
className,
|
||||
tooltip,
|
||||
width,
|
||||
transparent,
|
||||
as: Component = 'label',
|
||||
...rest
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getInlineLabelStyles(theme, width);
|
||||
|
||||
const styles = getInlineLabelStyles(theme, transparent, width);
|
||||
return (
|
||||
<Component className={cx(styles.label, className)} {...rest}>
|
||||
{children}
|
||||
@@ -46,7 +48,7 @@ export const InlineLabel: FunctionComponent<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const getInlineLabelStyles = (theme: GrafanaTheme, width?: number | 'auto') => {
|
||||
export const getInlineLabelStyles = (theme: GrafanaTheme, transparent = false, width?: number | 'auto') => {
|
||||
return {
|
||||
label: css`
|
||||
display: flex;
|
||||
@@ -56,7 +58,7 @@ export const getInlineLabelStyles = (theme: GrafanaTheme, width?: number | 'auto
|
||||
padding: 0 ${theme.spacing.sm};
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
background-color: ${theme.colors.bg2};
|
||||
background-color: ${transparent ? 'transparent' : theme.colors.bg2};
|
||||
height: ${theme.height.md}px;
|
||||
line-height: ${theme.height.md}px;
|
||||
margin-right: ${theme.spacing.xs};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
import { Placement } from '@popperjs/core';
|
||||
import { Tooltip } from '../../../Tooltip/Tooltip';
|
||||
import * as PopperJS from 'popper.js';
|
||||
import { Icon } from '../../..';
|
||||
|
||||
export interface Props {
|
||||
@@ -11,7 +11,7 @@ export interface Props {
|
||||
labelClass?: string;
|
||||
switchClass?: string;
|
||||
tooltip?: string;
|
||||
tooltipPlacement?: PopperJS.Placement;
|
||||
tooltipPlacement?: Placement;
|
||||
transparent?: boolean;
|
||||
onChange: (event: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const getGraphWithLegendStyles = stylesFactory(({ placement }: GraphWithLegendPr
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-direction: ${placement === 'bottom' ? 'column' : 'row'};
|
||||
height: 100%;
|
||||
`,
|
||||
graphContainer: css`
|
||||
min-height: 65%;
|
||||
|
||||
@@ -5,18 +5,27 @@ import {
|
||||
DisplayValue,
|
||||
FieldConfig,
|
||||
FieldMatcher,
|
||||
FieldMatcherID,
|
||||
fieldMatchers,
|
||||
fieldReducers,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
outerJoinDataFrames,
|
||||
reduceField,
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { alignDataFrames } from './utils';
|
||||
import { useTheme } from '../../themes';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { PlotProps } from '../uPlot/types';
|
||||
import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config';
|
||||
import {
|
||||
AxisPlacement,
|
||||
DrawStyle,
|
||||
GraphFieldConfig,
|
||||
PointVisibility,
|
||||
ScaleDirection,
|
||||
ScaleOrientation,
|
||||
} from '../uPlot/config';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
@@ -32,12 +41,18 @@ export interface XYFieldMatchers {
|
||||
x: FieldMatcher; // first match
|
||||
y: FieldMatcher;
|
||||
}
|
||||
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
|
||||
|
||||
export interface GraphNGProps {
|
||||
width: number;
|
||||
height: number;
|
||||
data: DataFrame[];
|
||||
timeRange: TimeRange;
|
||||
legend: VizLegendOptions;
|
||||
timeZone: TimeZone;
|
||||
fields?: XYFieldMatchers; // default will assume timeseries data
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
onSeriesColorChange?: (label: string, color: string) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultConfig: GraphFieldConfig = {
|
||||
@@ -64,9 +79,16 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
const theme = useTheme();
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
|
||||
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
|
||||
const alignedFrame = alignedFrameWithGapTest?.frame;
|
||||
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
|
||||
const frame = useMemo(() => {
|
||||
// Default to timeseries config
|
||||
if (!fields) {
|
||||
fields = {
|
||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||
};
|
||||
}
|
||||
return outerJoinDataFrames({ frames: data, joinBy: fields.x, keep: fields.y, keepOriginIndices: true });
|
||||
}, [data, fields]);
|
||||
|
||||
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
|
||||
if (a && b) {
|
||||
@@ -98,21 +120,24 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
currentTimeRange.current = timeRange;
|
||||
}, [timeRange]);
|
||||
|
||||
const configRev = useRevision(alignedFrame, compareFrames);
|
||||
const configRev = useRevision(frame, compareFrames);
|
||||
|
||||
const configBuilder = useMemo(() => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
if (!alignedFrame) {
|
||||
if (!frame) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
// X is the first field in the aligned frame
|
||||
const xField = alignedFrame.fields[0];
|
||||
const xField = frame.fields[0];
|
||||
let seriesIndex = 0;
|
||||
|
||||
if (xField.type === FieldType.time) {
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
orientation: ScaleOrientation.Horizontal,
|
||||
direction: ScaleDirection.Right,
|
||||
isTime: true,
|
||||
range: () => {
|
||||
const r = currentTimeRange.current!;
|
||||
@@ -131,6 +156,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
// Not time!
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
orientation: ScaleOrientation.Horizontal,
|
||||
direction: ScaleDirection.Right,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -141,8 +168,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
}
|
||||
let indexByName: Map<string, number> | undefined = undefined;
|
||||
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
const field = alignedFrame.fields[i];
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
const field = frame.fields[i];
|
||||
const config = field.config as FieldConfig<GraphFieldConfig>;
|
||||
const customConfig: GraphFieldConfig = {
|
||||
...defaultConfig,
|
||||
@@ -152,6 +179,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
if (field === xField || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
field.state!.seriesIndex = seriesIndex++;
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scaleKey = config.unit || FIXED_UNIT;
|
||||
@@ -159,18 +187,20 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
const scaleColor = getFieldSeriesColor(field, theme);
|
||||
const seriesColor = scaleColor.color;
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
builder.addScale({
|
||||
scaleKey,
|
||||
distribution: customConfig.scaleDistribution?.type,
|
||||
log: customConfig.scaleDistribution?.log,
|
||||
min: field.config.min,
|
||||
max: field.config.max,
|
||||
softMin: customConfig.axisSoftMin,
|
||||
softMax: customConfig.axisSoftMax,
|
||||
});
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
builder.addScale({
|
||||
scaleKey,
|
||||
orientation: ScaleOrientation.Vertical,
|
||||
direction: ScaleDirection.Up,
|
||||
distribution: customConfig.scaleDistribution?.type,
|
||||
log: customConfig.scaleDistribution?.log,
|
||||
min: field.config.min,
|
||||
max: field.config.max,
|
||||
softMin: customConfig.axisSoftMin,
|
||||
softMax: customConfig.axisSoftMax,
|
||||
});
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
builder.addAxis({
|
||||
scaleKey,
|
||||
label: customConfig.axisLabel,
|
||||
@@ -182,14 +212,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
}
|
||||
|
||||
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
|
||||
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
|
||||
|
||||
let { fillOpacity } = customConfig;
|
||||
if (customConfig.fillBelowTo) {
|
||||
if (!indexByName) {
|
||||
indexByName = getNamesToFieldIndex(alignedFrame);
|
||||
indexByName = getNamesToFieldIndex(frame);
|
||||
}
|
||||
const t = indexByName.get(getFieldDisplayName(field, alignedFrame));
|
||||
const t = indexByName.get(getFieldDisplayName(field, frame));
|
||||
const b = indexByName.get(customConfig.fillBelowTo);
|
||||
if (isNumber(b) && isNumber(t)) {
|
||||
builder.addBand({
|
||||
@@ -213,6 +242,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
lineWidth: customConfig.lineWidth,
|
||||
lineInterpolation: customConfig.lineInterpolation,
|
||||
lineStyle: customConfig.lineStyle,
|
||||
barAlignment: customConfig.barAlignment,
|
||||
pointSize: customConfig.pointSize,
|
||||
pointColor: customConfig.pointColor ?? seriesColor,
|
||||
spanNulls: customConfig.spanNulls || false,
|
||||
@@ -221,15 +251,15 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
thresholds: config.thresholds,
|
||||
|
||||
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
|
||||
dataFrameFieldIndex,
|
||||
fieldName: getFieldDisplayName(field, alignedFrame),
|
||||
dataFrameFieldIndex: field.state?.origin,
|
||||
fieldName: getFieldDisplayName(field, frame),
|
||||
hideInLegend: customConfig.hideFrom?.legend,
|
||||
});
|
||||
}
|
||||
return builder;
|
||||
}, [configRev, timeZone]);
|
||||
|
||||
if (alignedFrameWithGapTest == null) {
|
||||
if (!frame) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
@@ -262,6 +292,10 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
label: seriesConfig.fieldName,
|
||||
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
|
||||
getDisplayValues: () => {
|
||||
if (!legend.calcs?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const fieldCalcs = reduceField({
|
||||
field,
|
||||
@@ -299,7 +333,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
<VizLayout width={width} height={height} legend={legendElement}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={alignedFrameWithGapTest}
|
||||
data={frame}
|
||||
config={configBuilder}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { ArrayVector, DataFrame, FieldType, toDataFrame } from '@grafana/data';
|
||||
import { AlignedFrameWithGapTest } from '../uPlot/types';
|
||||
import { alignDataFrames, isLikelyAscendingVector } from './utils';
|
||||
|
||||
describe('alignDataFrames', () => {
|
||||
describe('aligned frame', () => {
|
||||
it('should align multiple data frames into one data frame', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature A',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature A',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature B',
|
||||
seriesIndex: 1,
|
||||
},
|
||||
name: 'temperature B',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([0, 2, 6, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame but only keep first time field', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time2', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature B',
|
||||
seriesIndex: 1,
|
||||
},
|
||||
name: 'temperature B',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([0, 2, 6, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const aligned = alignDataFrames(data);
|
||||
|
||||
expect(aligned?.frame.fields).toEqual([
|
||||
{
|
||||
config: {},
|
||||
state: {},
|
||||
name: 'time',
|
||||
type: FieldType.time,
|
||||
values: new ArrayVector([1000, 2000, 3000, 4000]),
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
state: {
|
||||
displayName: 'temperature',
|
||||
seriesIndex: 0,
|
||||
},
|
||||
name: 'temperature',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 5, 7]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataFrameFieldIndex', () => {
|
||||
let aligned: AlignedFrameWithGapTest | null;
|
||||
|
||||
beforeAll(() => {
|
||||
const data: DataFrame[] = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
{ name: 'humidity', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
|
||||
{ name: 'temperature C', type: FieldType.number, values: [0, 2, 6, 7] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
aligned = alignDataFrames(data);
|
||||
});
|
||||
|
||||
it.each`
|
||||
yDim | index
|
||||
${1} | ${[0, 1]}
|
||||
${2} | ${[1, 1]}
|
||||
${3} | ${[1, 2]}
|
||||
${4} | ${[2, 1]}
|
||||
`('should return correct index for yDim', ({ yDim, index }) => {
|
||||
const [frameIndex, fieldIndex] = index;
|
||||
|
||||
expect(aligned?.getDataFrameFieldIndex(yDim)).toEqual({
|
||||
frameIndex,
|
||||
fieldIndex,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('check ascending data', () => {
|
||||
it('simple ascending', () => {
|
||||
const v = new ArrayVector([1, 2, 3, 4, 5]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
});
|
||||
it('simple ascending with null', () => {
|
||||
const v = new ArrayVector([null, 2, 3, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
});
|
||||
it('single value', () => {
|
||||
const v = new ArrayVector([null, null, null, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeTruthy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('middle values', () => {
|
||||
const v = new ArrayVector([null, null, 5, 4, null]);
|
||||
expect(isLikelyAscendingVector(v)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('decending', () => {
|
||||
expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy();
|
||||
expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user