Visualizations: Dynamically set any config (min, max, unit, color, thresholds) from query results (#36548)

* initial steps for config from data

* Moving to core and separate transforms

* Progress

* Rows to fields are starting to work

* Config from query transform working

* UI progress

* More scenarios working

* Update public/app/core/components/TransformersUI/rowsToFields/rowsToFields.ts

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* transform all

* Refactor

* UI starting to work

* Add matcher UI to transform

* Apply to self

* Adding a reducer option

* Value mapping via new all values reducer

* value mappings workg add -A

* Minor changes

* Improving UI and adding test dashboards

* RowsToFieldsTransformerEditor tests

* Added tests for FieldToConfigMapping Editor

* Added test for ConfigFromQueryTransformerEditor

* Minor UI tweaks

* Added missing test

* Added label extraction

* unified mapping

* Progress refactoring

* Updates

* UI tweaks

* Rename

* Updates

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Torkel Ödegaard 2021-07-13 10:51:34 +02:00 committed by GitHub
parent e06335ffe9
commit 702fd1cad9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2818 additions and 104 deletions

View File

@ -8,6 +8,12 @@
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
@ -18,21 +24,26 @@
"links": [],
"panels": [
{
"folderId": null,
"datasource": null,
"gridPos": {
"h": 26,
"w": 6,
"x": 0,
"y": 0
},
"headings": true,
"id": 7,
"limit": 100,
"links": [],
"options": {
"folderId": null,
"maxItems": 100,
"query": "",
"recent": true,
"search": false,
"starred": true,
"showHeadings": true,
"showRecentlyViewed": true,
"showSearch": false,
"showStarred": true,
"tags": []
},
"pluginVersion": "8.1.0-pre",
"tags": [],
"timeFrom": null,
"timeShift": null,
@ -40,95 +51,167 @@
"type": "dashlist"
},
{
"folderId": null,
"datasource": null,
"gridPos": {
"h": 13,
"w": 6,
"x": 6,
"y": 0
},
"headings": false,
"id": 2,
"limit": 1000,
"links": [],
"options": {
"maxItems": 1000,
"query": "",
"recent": false,
"search": true,
"starred": false,
"tags": ["panel-tests"],
"showHeadings": false,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": [
"panel-tests"
]
},
"pluginVersion": "8.1.0-pre",
"tags": [
"panel-tests"
],
"timeFrom": null,
"timeShift": null,
"title": "tag: panel-tests",
"type": "dashlist"
},
{
"folderId": null,
"datasource": null,
"gridPos": {
"h": 26,
"h": 13,
"w": 6,
"x": 12,
"y": 0
},
"headings": false,
"id": 3,
"limit": 1000,
"links": [],
"options": {
"folderId": null,
"maxItems": 1000,
"query": "",
"recent": false,
"search": true,
"starred": false,
"tags": ["gdev", "demo"],
"showHeadings": false,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": [
"gdev",
"demo"
]
},
"pluginVersion": "8.1.0-pre",
"tags": [
"gdev",
"demo"
],
"timeFrom": null,
"timeShift": null,
"title": "tag: dashboard-demo",
"type": "dashlist"
},
{
"folderId": null,
"datasource": null,
"gridPos": {
"h": 26,
"w": 6,
"x": 18,
"y": 0
},
"headings": false,
"id": 5,
"limit": 1000,
"links": [],
"options": {
"folderId": null,
"maxItems": 1000,
"query": "",
"recent": false,
"search": true,
"starred": false,
"tags": ["gdev", "datasource-test"],
"showHeadings": false,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": [
"gdev",
"datasource-test"
]
},
"pluginVersion": "8.1.0-pre",
"tags": [
"gdev",
"datasource-test"
],
"timeFrom": null,
"timeShift": null,
"title": "Data source tests",
"type": "dashlist"
},
{
"folderId": null,
"datasource": null,
"gridPos": {
"h": 13,
"w": 6,
"x": 6,
"y": 13
},
"headings": false,
"id": 4,
"limit": 1000,
"links": [],
"options": {
"maxItems": 1000,
"query": "",
"recent": false,
"search": true,
"starred": false,
"tags": ["templating", "gdev"],
"showHeadings": false,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": [
"templating",
"gdev"
]
},
"pluginVersion": "8.1.0-pre",
"tags": [
"templating",
"gdev"
],
"timeFrom": null,
"timeShift": null,
"title": "tag: templating ",
"type": "dashlist"
},
{
"datasource": null,
"gridPos": {
"h": 13,
"w": 6,
"x": 12,
"y": 13
},
"id": 8,
"links": [],
"options": {
"maxItems": 1000,
"query": "",
"showHeadings": false,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": [
"gdev",
"transform"
]
},
"pluginVersion": "8.1.0-pre",
"tags": [
"gdev",
"demo"
],
"timeFrom": null,
"timeShift": null,
"title": "tag: transforms",
"type": "dashlist"
}
],
"schemaVersion": 18,
"schemaVersion": 30,
"style": "dark",
"tags": [],
"templating": {
@ -139,8 +222,29 @@
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "",
"title": "Grafana Dev Overview & Home",

View File

@ -0,0 +1,568 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"description": "",
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"hide": false,
"max": 100,
"min": 1,
"refId": "A",
"scenarioId": "random_walk",
"startValue": 50
},
{
"alias": "",
"csvContent": "min,max,threshold1\n1000,1000,8000\n0,100,80\n\n",
"refId": "config",
"scenarioId": "csv_content"
}
],
"title": "Min, max, threshold from separate query",
"transformations": [
{
"id": "configFromData",
"options": {
"configRefId": "config",
"mappings": []
}
}
],
"type": "timeseries"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "SensorA"
},
"properties": [
{
"id": "custom.displayMode",
"value": "color-text"
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 0
},
"id": 5,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name, Value, SensorA, MyUnit, MyColor\nGoogle, 10, 50, km/h, blue\nGoogle, 100, 100,km/h, orange\n",
"hide": false,
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Custom mappings and apply to self",
"transformations": [
{
"id": "configFromData",
"options": {
"applyTo": {
"id": "byName",
"options": "SensorA"
},
"applyToConfig": true,
"configRefId": "A",
"mappings": [
{
"configProperty": "unit",
"fieldName": "MyUnit",
"handlerKey": "unit"
},
{
"fieldName": "MyColor",
"handlerKey": "color"
}
]
}
}
],
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "center",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Value"
},
"properties": [
{
"id": "custom.displayMode",
"value": "color-background-solid"
}
]
}
]
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 9
},
"id": 7,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "ID, DisplayText\n21412312312, Homer\n12421412413, Simpsons \n12321312313, Bart",
"hide": false,
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Mapping data",
"transformations": [
{
"id": "configFromData",
"options": {
"applyToConfig": true,
"configRefId": "A",
"mappings": [
{
"fieldName": "Color",
"handlerKey": "mappings.color"
},
{
"fieldName": "Value",
"handlerKey": "mappings.value"
}
]
}
}
],
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "center",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Value"
},
"properties": [
{
"id": "custom.displayMode",
"value": "color-background-solid"
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 12,
"x": 12,
"y": 9
},
"id": 6,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Value, Color\nOK, blue\nPretty bad, red\nYay it's green, green\nSomething is off, orange\nNo idea, #88AA00\nAm I purple?, purple",
"hide": false,
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Value mappings from query result applied to itself",
"transformations": [
{
"id": "configFromData",
"options": {
"applyTo": {
"id": "byName",
"options": "Value"
},
"applyToConfig": true,
"configRefId": "A",
"mappings": [
{
"fieldName": "Color",
"handlerKey": "mappings.color"
},
{
"fieldName": "Value",
"handlerKey": "mappings.value"
}
]
}
}
],
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "center",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 14
},
"id": 8,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "ID, Value\n21412312312, 100\n12421412413, 20\n12321312313, 10",
"hide": false,
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Display data",
"transformations": [
{
"id": "configFromData",
"options": {
"applyToConfig": true,
"configRefId": "A",
"mappings": [
{
"fieldName": "Color",
"handlerKey": "mappings.color"
},
{
"fieldName": "Value",
"handlerKey": "mappings.value"
}
]
}
}
],
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMin": 0,
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 0,
"y": 19
},
"id": 9,
"options": {
"barWidth": 0.97,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"orientation": "horizontal",
"showValue": "auto",
"text": {},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "ID, Value\nA21412312312, 100\nA12421412413, 20\nA12321312313, 10\n",
"hide": false,
"refId": "data",
"scenarioId": "csv_content"
},
{
"csvContent": "ID, DisplayText\nA21412312312, Homer\nA12421412413, Marge \nA12321312313, Bart",
"hide": false,
"refId": "mappings",
"scenarioId": "csv_content"
}
],
"title": "Value mapping ID -> DisplayText from separate query",
"transformations": [
{
"id": "configFromData",
"options": {
"applyTo": {
"id": "byName",
"options": "ID"
},
"applyToConfig": false,
"configRefId": "mappings",
"mappings": [
{
"fieldName": "ID",
"handlerKey": "mappings.value"
},
{
"fieldName": "DisplayText",
"handlerKey": "mappings.text"
}
]
}
}
],
"type": "barchart"
}
],
"refresh": "",
"schemaVersion": 30,
"style": "dark",
"tags": [
"gdev",
"transform"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Transforms - Config from query",
"uid": "Juj4_7ink",
"version": 1
}

View File

@ -0,0 +1,615 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": "-- Dashboard --",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 0
},
"id": 8,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"panelId": 2,
"refId": "A"
}
],
"title": "Raw data",
"type": "table"
},
{
"datasource": "-- Dashboard --",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Value"
},
"properties": [
{
"id": "custom.width",
"value": 82
}
]
},
{
"matcher": {
"id": "byName",
"options": "Unit"
},
"properties": [
{
"id": "custom.width",
"value": 108
}
]
}
]
},
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 0
},
"id": 7,
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"panelId": 3,
"refId": "A"
}
],
"title": "Raw data",
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 5
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name,Value,Unit,Color\nTemperature,10,degree,green\nPressure,100,bar,blue\nSpeed,30,km/h,red",
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Unit and color from data",
"transformations": [
{
"id": "rowsToFields",
"options": {}
}
],
"type": "stat"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 5
},
"id": 3,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showThresholdLabels": true,
"showThresholdMarkers": true,
"text": {}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name,Value,Unit,min,max, threshold1\nTemperature,10,degree,0,50,30\nPressure,100,Pa,0,300,200\nSpeed,30,km/h,0,150,110",
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Min, Max & Thresholds from data",
"transformations": [
{
"id": "rowsToFields",
"options": {}
}
],
"type": "gauge"
},
{
"datasource": "-- Dashboard --",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 12
},
"id": 10,
"options": {
"showHeader": true
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"panelId": 9,
"refId": "A"
}
],
"title": "Raw data",
"type": "table"
},
{
"datasource": "-- Dashboard --",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"displayMode": "auto"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Value"
},
"properties": [
{
"id": "custom.width",
"value": 82
}
]
},
{
"matcher": {
"id": "byName",
"options": "Unit"
},
"properties": [
{
"id": "custom.width",
"value": 108
}
]
}
]
},
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 12
},
"id": 12,
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"panelId": 11,
"refId": "A"
}
],
"title": "Raw data (Custom mapping)",
"type": "table"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 17
},
"id": 9,
"options": {
"displayMode": "gradient",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"text": {}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name,Value,Unit,Min,Max\nTemperature,20,degree,0,50\nPressure,150,Pa,0,300\nSpeed,100,km/h,0,110",
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Min max from data",
"transformations": [
{
"id": "rowsToFields",
"options": {}
}
],
"type": "bargauge"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 17
},
"id": 11,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showThresholdLabels": true,
"showThresholdMarkers": true,
"text": {}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name,Value,Type,Quota, Warning\nTemperature,25,degree,50,30\nPressure,100,Pa,300,200\nSpeed,30,km/h,150,130",
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Custom mapping",
"transformations": [
{
"id": "rowsToFields",
"options": {
"mappings": [
{
"configProperty": "unit",
"fieldName": "Type",
"handlerKey": "unit"
},
{
"configProperty": "max",
"fieldName": "Quota",
"handlerKey": "max"
},
{
"configProperty": "threshold1",
"fieldName": "Warning",
"handlerKey": "threshold1"
}
]
}
}
],
"type": "gauge"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 24
},
"id": 13,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"csvContent": "Name, City, Country, Value\nSensorA, Stockholm, Sweden, 20\nSensorB, London, England, 50\nSensorC, New York, USA,100",
"refId": "A",
"scenarioId": "csv_content"
}
],
"title": "Extra string fields to labels",
"transformations": [
{
"id": "rowsToFields",
"options": {}
}
],
"type": "stat"
}
],
"refresh": "",
"schemaVersion": 30,
"style": "dark",
"tags": [
"gdev",
"transform"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Transforms - Rows to fields",
"uid": "PMtIInink",
"version": 1
}

View File

@ -18,15 +18,13 @@ export enum ReducerID {
diffperc = 'diffperc',
delta = 'delta',
step = 'step',
firstNotNull = 'firstNotNull',
lastNotNull = 'lastNotNull',
changeCount = 'changeCount',
distinctCount = 'distinctCount',
allIsZero = 'allIsZero',
allIsNull = 'allIsNull',
allValues = 'allValues',
}
// Internal function
@ -232,6 +230,13 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [
description: 'Percentage difference between first and last values',
standard: true,
},
{
id: ReducerID.allValues,
name: 'All values',
description: 'Returns an array with all values',
standard: false,
reduce: (field: Field) => ({ allValues: field.values.toArray() }),
},
]);
export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {

View File

@ -23,4 +23,6 @@ export enum DataTransformerID {
groupBy = 'groupBy',
sortBy = 'sortBy',
histogram = 'histogram',
configFromData = 'configFromData',
rowsToFields = 'rowsToFields',
}

View File

@ -110,9 +110,11 @@ export class Gauge extends PureComponent<Props> {
const valueWidthBase = Math.min(width, dimension * 1.3) * 0.9;
// remove gauge & marker width (on left and right side)
// and 10px is some padding that flot adds to the outer canvas
const valueWidth = valueWidthBase - ((gaugeWidth + (showThresholdMarkers ? thresholdMarkersWidth : 0)) * 2 + 10);
const valueWidth =
valueWidthBase -
((gaugeWidth + (showThresholdMarkers ? thresholdMarkersWidth : 0) + (showThresholdLabels ? 10 : 0)) * 2 + 10);
const fontSize = this.props.text?.valueSize ?? calculateFontSize(text, valueWidth, dimension, 1, gaugeWidth * 1.7);
const thresholdLabelFontSize = fontSize / 2.5;
const thresholdLabelFontSize = Math.max(fontSize / 2.5, 12);
let min = field.min ?? 0;
let max = field.max ?? 100;

View File

@ -28,7 +28,7 @@ func (p *testDataPlugin) handleCsvContentScenario(ctx context.Context, req *back
}
csvContent := model.Get("csvContent").MustString()
alias := model.Get("alias").MustString(q.RefID)
alias := model.Get("alias").MustString("")
frame, err := p.loadCsvContent(strings.NewReader(csvContent), alias)
if err != nil {

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { css, cx } from '@emotion/css';
import {
DataTransformerID,
@ -8,7 +8,6 @@ import {
TransformerRegistryItem,
TransformerUIProps,
} from '@grafana/data';
import { getAllFieldNamesFromDataFrames } from './OrganizeFieldsTransformerEditor';
import { Select, StatsPicker, stylesFactory } from '@grafana/ui';
import {
@ -16,6 +15,7 @@ import {
GroupByOperationID,
GroupByTransformerOptions,
} from '@grafana/data/src/transformations/transformers/groupBy';
import { useAllFieldNamesFromDataFrames } from './utils';
interface FieldProps {
fieldName: string;
@ -28,7 +28,7 @@ export const GroupByTransformerEditor: React.FC<TransformerUIProps<GroupByTransf
options,
onChange,
}) => {
const fieldNames = useMemo(() => getAllFieldNamesFromDataFrames(input), [input]);
const fieldNames = useAllFieldNamesFromDataFrames(input);
const onConfigChange = useCallback(
(fieldName: string) => (config: GroupByFieldOptions) => {
@ -47,7 +47,7 @@ export const GroupByTransformerEditor: React.FC<TransformerUIProps<GroupByTransf
return (
<div>
{fieldNames.map((key: string) => (
{fieldNames.map((key) => (
<GroupByFieldConfiguration
onConfigChange={onConfigChange(key)}
fieldName={key}

View File

@ -2,18 +2,17 @@ import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
import {
DataFrame,
DataTransformerID,
GrafanaTheme,
standardTransformers,
TransformerRegistryItem,
TransformerUIProps,
getFieldDisplayName,
} from '@grafana/data';
import { stylesFactory, useTheme, Input, IconButton, Icon, FieldValidationMessage } from '@grafana/ui';
import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize';
import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order';
import { useAllFieldNamesFromDataFrames } from './utils';
interface OrganizeFieldsTransformerEditorProps extends TransformerUIProps<OrganizeFieldsTransformerOptions> {}
@ -21,7 +20,7 @@ const OrganizeFieldsTransformerEditor: React.FC<OrganizeFieldsTransformerEditorP
const { options, input, onChange } = props;
const { indexByName, excludeByName, renameByName } = options;
const fieldNames = useMemo(() => getAllFieldNamesFromDataFrames(input), [input]);
const fieldNames = useAllFieldNamesFromDataFrames(input);
const orderedFieldNames = useMemo(() => orderFieldNamesByIndex(fieldNames, indexByName), [fieldNames, indexByName]);
const onToggleVisibility = useCallback(
@ -205,26 +204,6 @@ const orderFieldNamesByIndex = (fieldNames: string[], indexByName: Record<string
return fieldNames.sort(comparer);
};
export const getAllFieldNamesFromDataFrames = (input: DataFrame[]): string[] => {
if (!Array.isArray(input)) {
return [] as string[];
}
return Object.keys(
input.reduce((names, frame) => {
if (!frame || !Array.isArray(frame.fields)) {
return names;
}
return frame.fields.reduce((names, field) => {
const t = getFieldDisplayName(field, frame, input);
names[t] = true;
return names;
}, names);
}, {} as Record<string, boolean>)
);
};
export const organizeFieldsTransformRegistryItem: TransformerRegistryItem<OrganizeFieldsTransformerOptions> = {
id: DataTransformerID.organize,
editor: OrganizeFieldsTransformerEditor,

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import {
DataTransformerID,
SelectableValue,
@ -6,18 +6,17 @@ import {
TransformerRegistryItem,
TransformerUIProps,
} from '@grafana/data';
import { getAllFieldNamesFromDataFrames } from './OrganizeFieldsTransformerEditor';
import { Select } from '@grafana/ui';
import { SeriesToColumnsOptions } from '@grafana/data/src/transformations/transformers/seriesToColumns';
import { useAllFieldNamesFromDataFrames } from './utils';
export const SeriesToFieldsTransformerEditor: React.FC<TransformerUIProps<SeriesToColumnsOptions>> = ({
input,
options,
onChange,
}) => {
const fieldNames = useMemo(() => getAllFieldNamesFromDataFrames(input), [input]);
const fieldNameOptions = fieldNames.map((item: string) => ({ label: item, value: item }));
const fieldNames = useAllFieldNamesFromDataFrames(input).map((item: string) => ({ label: item, value: item }));
const onSelectField = useCallback(
(value: SelectableValue<string>) => {
@ -33,7 +32,7 @@ export const SeriesToFieldsTransformerEditor: React.FC<TransformerUIProps<Series
<div className="gf-form-inline">
<div className="gf-form gf-form--grow">
<div className="gf-form-label width-8">Field name</div>
<Select options={fieldNameOptions} value={options.byField} onChange={onSelectField} isClearable />
<Select options={fieldNames} value={options.byField} onChange={onSelectField} isClearable />
</div>
</div>
);

View File

@ -1,23 +1,15 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { DataTransformerID, standardTransformers, TransformerRegistryItem, TransformerUIProps } from '@grafana/data';
import { getAllFieldNamesFromDataFrames } from './OrganizeFieldsTransformerEditor';
import { InlineField, InlineSwitch, InlineFieldRow, Select } from '@grafana/ui';
import { SortByField, SortByTransformerOptions } from '@grafana/data/src/transformations/transformers/sortBy';
import { useAllFieldNamesFromDataFrames } from './utils';
export const SortByTransformerEditor: React.FC<TransformerUIProps<SortByTransformerOptions>> = ({
input,
options,
onChange,
}) => {
const fieldNames = useMemo(
() =>
getAllFieldNamesFromDataFrames(input).map((n) => ({
value: n,
label: n,
})),
[input]
);
const fieldNames = useAllFieldNamesFromDataFrames(input).map((item: string) => ({ label: item, value: item }));
// Only supports single sort for now
const onSortChange = useCallback(
@ -37,7 +29,7 @@ export const SortByTransformerEditor: React.FC<TransformerUIProps<SortByTransfor
<InlineField label="Field" labelWidth={10} grow={true}>
<Select
options={fieldNames}
value={fieldNames.find((v) => v.value === s.field)}
value={s.field}
placeholder="Select field"
onChange={(v) => {
onSortChange(index, { ...s, field: v.value! });

View File

@ -0,0 +1,51 @@
import React from 'react';
import { toDataFrame, FieldType } from '@grafana/data';
import { fireEvent, render, screen, getByText } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Props, ConfigFromQueryTransformerEditor } from './ConfigFromQueryTransformerEditor';
beforeEach(() => {
jest.clearAllMocks();
});
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature', 'Pressure'] },
{ name: 'Value', type: FieldType.number, values: [10, 200] },
{ name: 'Unit', type: FieldType.string, values: ['degree', 'pressurebar'] },
{ name: 'Miiin', type: FieldType.number, values: [3, 100] },
{ name: 'max', type: FieldType.string, values: [15, 200] },
],
refId: 'A',
});
const mockOnChange = jest.fn();
const props: Props = {
input: [input],
onChange: mockOnChange,
options: {
mappings: [],
},
};
const setup = (testProps?: Partial<Props>) => {
const editorProps = { ...props, ...testProps };
return render(<ConfigFromQueryTransformerEditor {...editorProps} />);
};
describe('ConfigFromQueryTransformerEditor', () => {
it('Should be able to select config frame by refId', async () => {
setup();
let select = (await screen.findByText('Config query')).nextSibling!;
await fireEvent.keyDown(select, { keyCode: 40 });
await userEvent.click(getByText(select as HTMLElement, 'A'));
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
configRefId: 'A',
})
);
});
});

View File

@ -0,0 +1,105 @@
import React from 'react';
import {
FieldMatcherID,
GrafanaTheme2,
PluginState,
SelectableValue,
TransformerRegistryItem,
TransformerUIProps,
} from '@grafana/data';
import { configFromDataTransformer, ConfigFromQueryTransformOptions } from './configFromQuery';
import { fieldMatchersUI, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
import { FieldToConfigMappingEditor } from '../fieldToConfigMapping/FieldToConfigMappingEditor';
import { css } from '@emotion/css';
export interface Props extends TransformerUIProps<ConfigFromQueryTransformOptions> {}
export function ConfigFromQueryTransformerEditor({ input, onChange, options }: Props) {
const styles = useStyles2(getStyles);
const refIds = input
.map((x) => x.refId)
.filter((x) => x != null)
.map((x) => ({ label: x, value: x }));
const currentRefId = options.configRefId || 'config';
const currentMatcher = options.applyTo ?? { id: FieldMatcherID.byType, options: 'number' };
const matcherUI = fieldMatchersUI.get(currentMatcher.id);
const configFrame = input.find((x) => x.refId === currentRefId);
const onRefIdChange = (value: SelectableValue<string>) => {
onChange({
...options,
configRefId: value.value || 'config',
});
};
const onMatcherChange = (value: SelectableValue<string>) => {
onChange({ ...options, applyTo: { id: value.value! } });
};
const onMatcherConfigChange = (matcherOption: any) => {
onChange({ ...options, applyTo: { id: currentMatcher.id, options: matcherOption } });
};
const matchers = fieldMatchersUI
.list()
.filter((o) => !o.excludeFromPicker)
.map<SelectableValue<string>>((i) => ({ label: i.name, value: i.id, description: i.description }));
return (
<>
<InlineFieldRow>
<InlineField label="Config query" labelWidth={20}>
<Select onChange={onRefIdChange} options={refIds} value={currentRefId} width={30} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Apply to" labelWidth={20}>
<Select onChange={onMatcherChange} options={matchers} value={currentMatcher.id} width={30} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Apply to options" labelWidth={20} className={styles.matcherOptions}>
<matcherUI.component
matcher={matcherUI.matcher}
data={input}
options={currentMatcher.options}
onChange={onMatcherConfigChange}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
{configFrame && (
<FieldToConfigMappingEditor
frame={configFrame}
mappings={options.mappings}
onChange={(mappings) => onChange({ ...options, mappings })}
withReducers
/>
)}
</InlineFieldRow>
</>
);
}
export const configFromQueryTransformRegistryItem: TransformerRegistryItem<ConfigFromQueryTransformOptions> = {
id: configFromDataTransformer.id,
editor: ConfigFromQueryTransformerEditor,
transformation: configFromDataTransformer,
name: configFromDataTransformer.name,
description: configFromDataTransformer.description,
state: PluginState.beta,
help: `
### Use cases
Can take a query result and extract properties like min and max and apply it to the other query results.
`,
};
const getStyles = (theme: GrafanaTheme2) => ({
matcherOptions: css`
min-width: 404px;
`,
});

View File

@ -0,0 +1,157 @@
import { toDataFrame, FieldType, ReducerID } from '@grafana/data';
import { FieldConfigHandlerKey } from '../fieldToConfigMapping/fieldToConfigMapping';
import { extractConfigFromQuery, ConfigFromQueryTransformOptions } from './configFromQuery';
describe('config from data', () => {
const config = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2] },
{ name: 'Max', type: FieldType.number, values: [1, 10, 50] },
{ name: 'Min', type: FieldType.number, values: [1, 10, 5] },
{ name: 'Names', type: FieldType.string, values: ['first-name', 'middle', 'last-name'] },
],
refId: 'A',
});
const seriesA = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3] },
{
name: 'Value',
type: FieldType.number,
values: [2, 3, 4],
config: { displayName: 'SeriesA' },
},
],
});
it('Select and apply with two frames and default mappings and reducer', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
expect(results[0].fields[1].config.max).toBe(50);
expect(results[0].fields[1].config.min).toBe(5);
});
it('Can apply to config frame if there is only one frame', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [],
};
const results = extractConfigFromQuery(options, [config]);
expect(results.length).toBe(1);
expect(results[0].fields[1].name).toBe('Max');
expect(results[0].fields[1].config.max).toBe(50);
});
it('With ignore mappings', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [{ fieldName: 'Min', handlerKey: FieldConfigHandlerKey.Ignore }],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
expect(results[0].fields[1].config.min).toEqual(undefined);
expect(results[0].fields[1].config.max).toEqual(50);
});
it('With custom mappings', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [{ fieldName: 'Min', handlerKey: 'decimals' }],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
expect(results[0].fields[1].config.decimals).toBe(5);
});
it('With custom reducer', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [{ fieldName: 'Max', handlerKey: 'max', reducerId: ReducerID.min }],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
expect(results[0].fields[1].config.max).toBe(1);
});
it('With custom matcher and displayName mapping', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'A',
mappings: [{ fieldName: 'Names', handlerKey: 'displayName', reducerId: ReducerID.first }],
applyTo: { id: 'byName', options: 'Value' },
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results.length).toBe(1);
expect(results[0].fields[1].config.displayName).toBe('first-name');
});
});
describe('value mapping from data', () => {
const config = toDataFrame({
fields: [
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
{ name: 'text', type: FieldType.string, values: ['one', 'two', 'three'] },
{ name: 'color', type: FieldType.string, values: ['red', 'blue', 'green'] },
],
refId: 'config',
});
const seriesA = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3] },
{
name: 'Value',
type: FieldType.number,
values: [1, 2, 3],
config: {},
},
],
});
it('Should take all field values and map to value mappings', () => {
const options: ConfigFromQueryTransformOptions = {
configRefId: 'config',
mappings: [
{ fieldName: 'value', handlerKey: 'mappings.value' },
{ fieldName: 'color', handlerKey: 'mappings.color' },
{ fieldName: 'text', handlerKey: 'mappings.text' },
],
};
const results = extractConfigFromQuery(options, [config, seriesA]);
expect(results[0].fields[1].config.mappings).toMatchInlineSnapshot(`
Array [
Object {
"options": Object {
"1": Object {
"color": "red",
"index": 0,
"text": "one",
},
"2": Object {
"color": "blue",
"index": 1,
"text": "two",
},
"3": Object {
"color": "green",
"index": 2,
"text": "three",
},
},
"type": "value",
},
]
`);
});
});

View File

@ -0,0 +1,105 @@
import { map } from 'rxjs/operators';
import {
ArrayVector,
DataFrame,
DataTransformerID,
DataTransformerInfo,
FieldMatcherID,
getFieldDisplayName,
getFieldMatcher,
MatcherConfig,
reduceField,
} from '@grafana/data';
import {
getFieldConfigFromFrame,
FieldToConfigMapping,
evaluteFieldMappings,
} from '../fieldToConfigMapping/fieldToConfigMapping';
export interface ConfigFromQueryTransformOptions {
configRefId?: string;
mappings: FieldToConfigMapping[];
applyTo?: MatcherConfig;
}
export function extractConfigFromQuery(options: ConfigFromQueryTransformOptions, data: DataFrame[]) {
let configFrame: DataFrame | null = null;
for (const frame of data) {
if (frame.refId === options.configRefId) {
configFrame = frame;
break;
}
}
if (!configFrame) {
return data;
}
const reducedConfigFrame: DataFrame = {
fields: [],
length: 1,
};
const mappingResult = evaluteFieldMappings(configFrame, options.mappings ?? [], false);
// reduce config frame
for (const field of configFrame.fields) {
const newField = { ...field };
const fieldName = getFieldDisplayName(field, configFrame);
const fieldMapping = mappingResult.index[fieldName];
const result = reduceField({ field, reducers: [fieldMapping.reducerId] });
newField.values = new ArrayVector([result[fieldMapping.reducerId]]);
reducedConfigFrame.fields.push(newField);
}
const output: DataFrame[] = [];
const matcher = getFieldMatcher(options.applyTo || { id: FieldMatcherID.numeric });
for (const frame of data) {
// Skip config frame in output
if (frame === configFrame && data.length > 1) {
continue;
}
const outputFrame: DataFrame = {
fields: [],
length: frame.length,
};
for (const field of frame.fields) {
if (matcher(field, frame, data)) {
const dataConfig = getFieldConfigFromFrame(reducedConfigFrame, 0, mappingResult);
outputFrame.fields.push({
...field,
config: {
...field.config,
...dataConfig,
},
});
} else {
outputFrame.fields.push(field);
}
}
output.push(outputFrame);
}
return output;
}
export const configFromDataTransformer: DataTransformerInfo<ConfigFromQueryTransformOptions> = {
id: DataTransformerID.configFromData,
name: 'Config from query results',
description: 'Set unit, min, max and more from data',
defaultOptions: {
configRefId: 'config',
mappings: [],
},
/**
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
operator: (options) => (source) => source.pipe(map((data) => extractConfigFromQuery(options, data))),
};

View File

@ -0,0 +1,89 @@
import React from 'react';
import { toDataFrame, FieldType } from '@grafana/data';
import { fireEvent, render, screen, getByText, getByLabelText } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Props, FieldToConfigMappingEditor } from './FieldToConfigMappingEditor';
beforeEach(() => {
jest.clearAllMocks();
});
const frame = toDataFrame({
fields: [
{ name: 'Unit', type: FieldType.string, values: ['degree', 'pressurebar'] },
{ name: 'Miiin', type: FieldType.number, values: [3, 100] },
{ name: 'max', type: FieldType.string, values: [15, 200] },
],
});
const mockOnChange = jest.fn();
const props: Props = {
frame: frame,
onChange: mockOnChange,
mappings: [],
withReducers: true,
};
const setup = (testProps?: Partial<Props>) => {
const editorProps = { ...props, ...testProps };
return render(<FieldToConfigMappingEditor {...editorProps} />);
};
describe('FieldToConfigMappingEditor', () => {
it('Should render fields', async () => {
setup();
expect(await screen.findByText('Unit')).toBeInTheDocument();
expect(await screen.findByText('Miiin')).toBeInTheDocument();
expect(await screen.findByText('max')).toBeInTheDocument();
expect(await screen.findByText('Max (auto)')).toBeInTheDocument();
});
it('Can change mapping', async () => {
setup();
const select = (await screen.findByTestId('Miiin-config-key')).childNodes[0];
await fireEvent.keyDown(select, { keyCode: 40 });
await userEvent.click(getByText(select as HTMLElement, 'Min'));
expect(mockOnChange).toHaveBeenCalledWith(expect.arrayContaining([{ fieldName: 'Miiin', handlerKey: 'min' }]));
});
it('Can remove added mapping', async () => {
setup({ mappings: [{ fieldName: 'max', handlerKey: 'min' }] });
const select = (await screen.findByTestId('max-config-key')).childNodes[0];
await userEvent.click(getByLabelText(select as HTMLElement, 'select-clear-value'));
expect(mockOnChange).toHaveBeenCalledWith(expect.arrayContaining([]));
});
it('Automatic mapping is shown as placeholder', async () => {
setup({ mappings: [] });
const select = await screen.findByText('Max (auto)');
expect(select).toBeInTheDocument();
});
it('Should show correct default reducer', async () => {
setup({ mappings: [{ fieldName: 'max', handlerKey: 'mappings.value' }] });
const reducer = await screen.findByTestId('max-reducer');
expect(getByText(reducer, 'All values')).toBeInTheDocument();
});
it('Can change reducer', async () => {
setup();
const reducer = await (await screen.findByTestId('max-reducer')).childNodes[0];
await fireEvent.keyDown(reducer, { keyCode: 40 });
await userEvent.click(getByText(reducer as HTMLElement, 'Last'));
expect(mockOnChange).toHaveBeenCalledWith(
expect.arrayContaining([{ fieldName: 'max', handlerKey: 'max', reducerId: 'last' }])
);
});
});

View File

@ -0,0 +1,197 @@
import React from 'react';
import { DataFrame, getFieldDisplayName, GrafanaTheme2, ReducerID, SelectableValue } from '@grafana/data';
import { Select, StatsPicker, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import {
configMapHandlers,
evaluteFieldMappings,
FieldToConfigMapHandler,
FieldToConfigMapping,
lookUpConfigHandler as findConfigHandlerFor,
} from '../fieldToConfigMapping/fieldToConfigMapping';
import { capitalize } from 'lodash';
export interface Props {
frame: DataFrame;
mappings: FieldToConfigMapping[];
onChange: (mappings: FieldToConfigMapping[]) => void;
withReducers?: boolean;
withNameAndValue?: boolean;
}
export function FieldToConfigMappingEditor({ frame, mappings, onChange, withReducers, withNameAndValue }: Props) {
const styles = useStyles2(getStyles);
const rows = getViewModelRows(frame, mappings, withNameAndValue);
const configProps = configMapHandlers.map((def) => configHandlerToSelectOption(def, false)) as Array<
SelectableValue<string>
>;
const onChangeConfigProperty = (row: FieldToConfigRowViewModel, value: SelectableValue<string | null>) => {
const existingIdx = mappings.findIndex((x) => x.fieldName === row.fieldName);
if (value) {
if (existingIdx !== -1) {
const update = [...mappings];
update.splice(existingIdx, 1, { ...mappings[existingIdx], handlerKey: value.value! });
onChange(update);
} else {
onChange([...mappings, { fieldName: row.fieldName, handlerKey: value.value! }]);
}
} else {
if (existingIdx !== -1) {
onChange(mappings.filter((x, index) => index !== existingIdx));
} else {
onChange([...mappings, { fieldName: row.fieldName, handlerKey: '__ignore' }]);
}
}
};
const onChangeReducer = (row: FieldToConfigRowViewModel, reducerId: ReducerID) => {
const existingIdx = mappings.findIndex((x) => x.fieldName === row.fieldName);
if (existingIdx !== -1) {
const update = [...mappings];
update.splice(existingIdx, 1, { ...mappings[existingIdx], reducerId });
onChange(update);
} else {
onChange([...mappings, { fieldName: row.fieldName, handlerKey: row.handlerKey, reducerId }]);
}
};
return (
<table className={styles.table}>
<thead>
<tr>
<th>Field</th>
<th>Use as</th>
{withReducers && <th>Select</th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.fieldName}>
<td className={styles.labelCell}>{row.fieldName}</td>
<td className={styles.selectCell} data-testid={`${row.fieldName}-config-key`}>
<Select
options={configProps}
value={row.configOption}
placeholder={row.placeholder}
isClearable={true}
onChange={(value) => onChangeConfigProperty(row, value)}
/>
</td>
{withReducers && (
<td data-testid={`${row.fieldName}-reducer`} className={styles.selectCell}>
<StatsPicker
stats={[row.reducerId]}
defaultStat={row.reducerId}
onChange={(stats: string[]) => onChangeReducer(row, stats[0] as ReducerID)}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
);
}
interface FieldToConfigRowViewModel {
handlerKey: string | null;
fieldName: string;
configOption: SelectableValue<string | null> | null;
placeholder?: string;
missingInFrame?: boolean;
reducerId: string;
}
function getViewModelRows(
frame: DataFrame,
mappings: FieldToConfigMapping[],
withNameAndValue?: boolean
): FieldToConfigRowViewModel[] {
const rows: FieldToConfigRowViewModel[] = [];
const mappingResult = evaluteFieldMappings(frame, mappings ?? [], withNameAndValue);
for (const field of frame.fields) {
const fieldName = getFieldDisplayName(field, frame);
const mapping = mappingResult.index[fieldName];
const option = configHandlerToSelectOption(mapping.handler, mapping.automatic);
rows.push({
fieldName,
configOption: mapping.automatic ? null : option,
placeholder: mapping.automatic ? option?.label : 'Choose',
handlerKey: mapping.handler?.key ?? null,
reducerId: mapping.reducerId,
});
}
// Add rows for mappings that have no matching field
for (const mapping of mappings) {
if (!rows.find((x) => x.fieldName === mapping.fieldName)) {
const handler = findConfigHandlerFor(mapping.handlerKey);
rows.push({
fieldName: mapping.fieldName,
handlerKey: mapping.handlerKey,
configOption: configHandlerToSelectOption(handler, false),
missingInFrame: true,
reducerId: mapping.reducerId ?? ReducerID.lastNotNull,
});
}
}
return Object.values(rows);
}
function configHandlerToSelectOption(
def: FieldToConfigMapHandler | null,
isAutomatic: boolean
): SelectableValue<string> | null {
if (!def) {
return null;
}
let name = def.name ?? capitalize(def.key);
if (isAutomatic) {
name = `${name} (auto)`;
}
return {
label: name,
value: def.key,
};
}
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
margin-top: ${theme.spacing(1)};
td,
th {
border-right: 4px solid ${theme.colors.background.primary};
border-bottom: 4px solid ${theme.colors.background.primary};
white-space: nowrap;
}
th {
font-size: ${theme.typography.bodySmall.fontSize};
line-height: ${theme.spacing(4)};
padding: ${theme.spacing(0, 1)};
}
`,
labelCell: css`
font-size: ${theme.typography.bodySmall.fontSize};
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(0, 1)};
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
min-width: 140px;
`,
selectCell: css`
padding: 0;
min-width: 161px;
`,
});

View File

@ -0,0 +1,344 @@
import {
anyToNumber,
DataFrame,
FieldColorModeId,
FieldConfig,
getFieldDisplayName,
MappingType,
ReducerID,
ThresholdsMode,
ValueMapping,
ValueMap,
Field,
FieldType,
} from '@grafana/data';
import { isArray } from 'lodash';
export interface FieldToConfigMapping {
fieldName: string;
reducerId?: ReducerID;
handlerKey: string | null;
}
/**
* Transforms a frame with fields to a map of field configs
*
* Input
* | Unit | Min | Max |
* --------------------------------
* | Temperature | 0 | 30 |
* | Pressure | 0 | 100 |
*
* Outputs
* {
{ min: 0, max: 100 },
* }
*/
export function getFieldConfigFromFrame(
frame: DataFrame,
rowIndex: number,
evaluatedMappings: EvaluatedMappingResult
): FieldConfig {
const config: FieldConfig = {};
const context: FieldToConfigContext = {};
for (const field of frame.fields) {
const fieldName = getFieldDisplayName(field, frame);
const mapping = evaluatedMappings.index[fieldName];
const handler = mapping.handler;
if (!handler) {
continue;
}
const configValue = field.values.get(rowIndex);
if (configValue === null || configValue === undefined) {
continue;
}
const newValue = handler.processor(configValue, config, context);
if (newValue != null) {
(config as any)[handler.targetProperty ?? handler.key] = newValue;
}
}
if (context.mappingValues) {
config.mappings = combineValueMappings(context);
}
return config;
}
interface FieldToConfigContext {
mappingValues?: any[];
mappingColors?: string[];
mappingTexts?: string[];
}
type FieldToConfigMapHandlerProcessor = (value: any, config: FieldConfig, context: FieldToConfigContext) => any;
export interface FieldToConfigMapHandler {
key: string;
targetProperty?: string;
name?: string;
processor: FieldToConfigMapHandlerProcessor;
defaultReducer?: ReducerID;
}
export enum FieldConfigHandlerKey {
Name = 'field.name',
Value = 'field.value',
Label = 'field.label',
Ignore = '__ignore',
}
export const configMapHandlers: FieldToConfigMapHandler[] = [
{
key: FieldConfigHandlerKey.Name,
name: 'Field name',
processor: () => {},
},
{
key: FieldConfigHandlerKey.Value,
name: 'Field value',
processor: () => {},
},
{
key: FieldConfigHandlerKey.Label,
name: 'Field label',
processor: () => {},
},
{
key: FieldConfigHandlerKey.Ignore,
name: 'Ignore',
processor: () => {},
},
{
key: 'max',
processor: toNumericOrUndefined,
},
{
key: 'min',
processor: toNumericOrUndefined,
},
{
key: 'unit',
processor: (value) => value.toString(),
},
{
key: 'decimals',
processor: toNumericOrUndefined,
},
{
key: 'displayName',
name: 'Display name',
processor: (value: any) => value.toString(),
},
{
key: 'color',
processor: (value) => ({ fixedColor: value, mode: FieldColorModeId.Fixed }),
},
{
key: 'threshold1',
targetProperty: 'thresholds',
processor: (value, config) => {
const numeric = anyToNumber(value);
if (isNaN(numeric)) {
return;
}
if (!config.thresholds) {
config.thresholds = {
mode: ThresholdsMode.Absolute,
steps: [{ value: -Infinity, color: 'green' }],
};
}
config.thresholds.steps.push({
value: numeric,
color: 'red',
});
return config.thresholds;
},
},
{
key: 'mappings.value',
name: 'Value mappings / Value',
targetProperty: 'mappings',
defaultReducer: ReducerID.allValues,
processor: (value, config, context) => {
if (!isArray(value)) {
return;
}
context.mappingValues = value;
return config.mappings;
},
},
{
key: 'mappings.color',
name: 'Value mappings / Color',
targetProperty: 'mappings',
defaultReducer: ReducerID.allValues,
processor: (value, config, context) => {
if (!isArray(value)) {
return;
}
context.mappingColors = value;
return config.mappings;
},
},
{
key: 'mappings.text',
name: 'Value mappings / Display text',
targetProperty: 'mappings',
defaultReducer: ReducerID.allValues,
processor: (value, config, context) => {
if (!isArray(value)) {
return;
}
context.mappingTexts = value;
return config.mappings;
},
},
];
function combineValueMappings(context: FieldToConfigContext): ValueMapping[] {
const valueMap: ValueMap = {
type: MappingType.ValueToText,
options: {},
};
if (!context.mappingValues) {
return [];
}
for (let i = 0; i < context.mappingValues.length; i++) {
const value = context.mappingValues[i];
if (value != null) {
valueMap.options[value.toString()] = {
color: context.mappingColors && context.mappingColors[i],
text: context.mappingTexts && context.mappingTexts[i],
index: i,
};
}
}
return [valueMap];
}
let configMapHandlersIndex: Record<string, FieldToConfigMapHandler> | null = null;
export function getConfigMapHandlersIndex() {
if (configMapHandlersIndex === null) {
configMapHandlersIndex = {};
for (const def of configMapHandlers) {
configMapHandlersIndex[def.key] = def;
}
}
return configMapHandlersIndex;
}
function toNumericOrUndefined(value: any) {
const numeric = anyToNumber(value);
if (isNaN(numeric)) {
return;
}
return numeric;
}
export function getConfigHandlerKeyForField(fieldName: string, mappings: FieldToConfigMapping[]) {
for (const map of mappings) {
if (fieldName === map.fieldName) {
return map.handlerKey;
}
}
return fieldName.toLowerCase();
}
export function lookUpConfigHandler(key: string | null): FieldToConfigMapHandler | null {
if (!key) {
return null;
}
return getConfigMapHandlersIndex()[key];
}
export interface EvaluatedMapping {
automatic: boolean;
handler: FieldToConfigMapHandler | null;
reducerId: ReducerID;
}
export interface EvaluatedMappingResult {
index: Record<string, EvaluatedMapping>;
nameField?: Field;
valueField?: Field;
}
export function evaluteFieldMappings(
frame: DataFrame,
mappings: FieldToConfigMapping[],
withNameAndValue?: boolean
): EvaluatedMappingResult {
const result: EvaluatedMappingResult = {
index: {},
};
// Look up name and value field in mappings
let nameFieldMappping = mappings.find((x) => x.handlerKey === FieldConfigHandlerKey.Name);
let valueFieldMapping = mappings.find((x) => x.handlerKey === FieldConfigHandlerKey.Value);
for (const field of frame.fields) {
const fieldName = getFieldDisplayName(field, frame);
const mapping = mappings.find((x) => x.fieldName === fieldName);
const key = mapping ? mapping.handlerKey : fieldName.toLowerCase();
let handler = lookUpConfigHandler(key);
// Name and value handlers are a special as their auto logic is based on first matching criteria
if (withNameAndValue) {
// If we have a handler it means manually specified field
if (handler) {
if (handler.key === FieldConfigHandlerKey.Name) {
result.nameField = field;
}
if (handler.key === FieldConfigHandlerKey.Value) {
result.valueField = field;
}
} else if (!mapping) {
// We have no name field and no mapping for it, pick first string
if (!result.nameField && !nameFieldMappping && field.type === FieldType.string) {
result.nameField = field;
handler = lookUpConfigHandler(FieldConfigHandlerKey.Name);
}
if (!result.valueField && !valueFieldMapping && field.type === FieldType.number) {
result.valueField = field;
handler = lookUpConfigHandler(FieldConfigHandlerKey.Value);
}
}
}
// If no handle and when in name and value mode (Rows to fields) default to labels
if (!handler && withNameAndValue) {
handler = lookUpConfigHandler(FieldConfigHandlerKey.Label);
}
result.index[fieldName] = {
automatic: !mapping,
handler: handler,
reducerId: mapping?.reducerId ?? handler?.defaultReducer ?? ReducerID.lastNotNull,
};
}
return result;
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import { toDataFrame, FieldType } from '@grafana/data';
import { fireEvent, render, screen, getByText } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Props, RowsToFieldsTransformerEditor } from './RowsToFieldsTransformerEditor';
beforeEach(() => {
jest.clearAllMocks();
});
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature', 'Pressure'] },
{ name: 'Value', type: FieldType.number, values: [10, 200] },
{ name: 'Unit', type: FieldType.string, values: ['degree', 'pressurebar'] },
{ name: 'Miiin', type: FieldType.number, values: [3, 100] },
{ name: 'max', type: FieldType.string, values: [15, 200] },
],
});
const mockOnChange = jest.fn();
const props: Props = {
input: [input],
onChange: mockOnChange,
options: {},
};
const setup = (testProps?: Partial<Props>) => {
const editorProps = { ...props, ...testProps };
return render(<RowsToFieldsTransformerEditor {...editorProps} />);
};
describe('RowsToFieldsTransformerEditor', () => {
it('Should be able to select name field', async () => {
setup();
const select = (await screen.findByTestId('Name-config-key')).childNodes[0];
await fireEvent.keyDown(select, { keyCode: 40 });
await userEvent.click(getByText(select as HTMLElement, 'Field name'));
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
mappings: [{ fieldName: 'Name', handlerKey: 'field.name' }],
})
);
});
it('Should be able to select value field', async () => {
setup();
const select = (await screen.findByTestId('Value-config-key')).childNodes[0];
await fireEvent.keyDown(select, { keyCode: 40 });
await userEvent.click(getByText(select as HTMLElement, 'Field value'));
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
mappings: [{ fieldName: 'Value', handlerKey: 'field.value' }],
})
);
});
});

View File

@ -0,0 +1,68 @@
import React from 'react';
import { PluginState, TransformerRegistryItem, TransformerUIProps } from '@grafana/data';
import { rowsToFieldsTransformer, RowToFieldsTransformOptions } from './rowsToFields';
import { FieldToConfigMappingEditor } from '../fieldToConfigMapping/FieldToConfigMappingEditor';
export interface Props extends TransformerUIProps<RowToFieldsTransformOptions> {}
export function RowsToFieldsTransformerEditor({ input, options, onChange }: Props) {
if (input.length === 0) {
return null;
}
return (
<div>
<FieldToConfigMappingEditor
frame={input[0]}
mappings={options.mappings ?? []}
onChange={(mappings) => onChange({ ...options, mappings })}
withNameAndValue={true}
/>
</div>
);
}
export const rowsToFieldsTransformRegistryItem: TransformerRegistryItem<RowToFieldsTransformOptions> = {
id: rowsToFieldsTransformer.id,
editor: RowsToFieldsTransformerEditor,
transformation: rowsToFieldsTransformer,
name: rowsToFieldsTransformer.name,
description: rowsToFieldsTransformer.description,
state: PluginState.beta,
help: `
### Use cases
This transformation transforms rows into separate fields. This can be useful as fields can be styled
and configured individually, something rows cannot. It can also use additional fields as sources for
data driven configuration or as sources for field labels. The additional labels can then be used to
define better display names for the resulting fields.
Useful when visualization data in:
* Gauge
* Stat
* Pie chart
### Configuration overview
* Select one field to use as the source of names for the new fields.
* Select one field to use as the values for the fields.
* Optionally map extra fields to config properties like min and max.
### Examples
Input:
Name | Value | Max
--------|-------|------
ServerA | 10 | 100
ServerB | 20 | 200
ServerC | 30 | 300
Output:
ServerA (max=100) | ServerB (max=200) | ServerC (max=300)
------------------|------------------ | ------------------
10 | 20 | 30
`,
};

View File

@ -0,0 +1,154 @@
import { toDataFrame, FieldType } from '@grafana/data';
import { rowsToFields } from './rowsToFields';
describe('Rows to fields', () => {
it('Will extract min & max from field', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature', 'Pressure'] },
{ name: 'Value', type: FieldType.number, values: [10, 200] },
{ name: 'Unit', type: FieldType.string, values: ['degree', 'pressurebar'] },
{ name: 'Miiin', type: FieldType.number, values: [3, 100] },
{ name: 'max', type: FieldType.string, values: [15, 200] },
],
});
const result = rowsToFields(
{
mappings: [
{
fieldName: 'Miiin',
handlerKey: 'min',
},
],
},
input
);
expect(result).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {
"max": 15,
"min": 3,
"unit": "degree",
},
"labels": Object {},
"name": "Temperature",
"type": "number",
"values": Array [
10,
],
},
Object {
"config": Object {
"max": 200,
"min": 100,
"unit": "pressurebar",
},
"labels": Object {},
"name": "Pressure",
"type": "number",
"values": Array [
200,
],
},
],
"length": 1,
}
`);
});
it('Can handle custom name and value field mapping', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Ignore'] },
{ name: 'SensorName', type: FieldType.string, values: ['Temperature'] },
{ name: 'Value', type: FieldType.number, values: [10] },
{ name: 'SensorReading', type: FieldType.number, values: [100] },
],
});
const result = rowsToFields(
{
mappings: [
{ fieldName: 'SensorName', handlerKey: 'field.name' },
{ fieldName: 'SensorReading', handlerKey: 'field.value' },
],
},
input
);
expect(result.fields[0].name).toBe('Temperature');
expect(result.fields[0].config).toEqual({});
expect(result.fields[0].values.get(0)).toBe(100);
});
it('Can handle colors', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature'] },
{ name: 'Value', type: FieldType.number, values: [10] },
{ name: 'Color', type: FieldType.string, values: ['blue'] },
],
});
const result = rowsToFields({}, input);
expect(result.fields[0].config.color?.fixedColor).toBe('blue');
});
it('Can handle thresholds', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature'] },
{ name: 'Value', type: FieldType.number, values: [10] },
{ name: 'threshold1', type: FieldType.string, values: [30] },
{ name: 'threshold2', type: FieldType.string, values: [500] },
],
});
const result = rowsToFields({}, input);
expect(result.fields[0].config.thresholds?.steps[1].value).toBe(30);
});
it('Will extract other string fields to labels', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature', 'Pressure'] },
{ name: 'Value', type: FieldType.number, values: [10, 200] },
{ name: 'City', type: FieldType.string, values: ['Stockholm', 'New York'] },
],
});
const result = rowsToFields({}, input);
expect(result.fields[0].labels).toEqual({ City: 'Stockholm' });
expect(result.fields[1].labels).toEqual({ City: 'New York' });
});
it('Can ignore field as auto picked for value or name', () => {
const input = toDataFrame({
fields: [
{ name: 'Name', type: FieldType.string, values: ['Temperature'] },
{ name: 'Value', type: FieldType.number, values: [10] },
{ name: 'City', type: FieldType.string, values: ['Stockholm'] },
{ name: 'Value2', type: FieldType.number, values: [20] },
],
});
const result = rowsToFields(
{
mappings: [
{ fieldName: 'Name', handlerKey: '__ignore' },
{ fieldName: 'Value', handlerKey: '__ignore' },
],
},
input
);
expect(result.fields[0].name).toEqual('Stockholm');
expect(result.fields[0].values.get(0)).toEqual(20);
});
});

View File

@ -0,0 +1,95 @@
import { map } from 'rxjs/operators';
import {
ArrayVector,
DataFrame,
DataTransformerID,
DataTransformerInfo,
Field,
getFieldDisplayName,
Labels,
} from '@grafana/data';
import {
getFieldConfigFromFrame,
FieldToConfigMapping,
evaluteFieldMappings,
EvaluatedMappingResult,
FieldConfigHandlerKey,
} from '../fieldToConfigMapping/fieldToConfigMapping';
export interface RowToFieldsTransformOptions {
nameField?: string;
valueField?: string;
mappings?: FieldToConfigMapping[];
}
export const rowsToFieldsTransformer: DataTransformerInfo<RowToFieldsTransformOptions> = {
id: DataTransformerID.rowsToFields,
name: 'Rows to fields',
description: 'Convert each row into a field with dynamic config',
defaultOptions: {},
/**
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
operator: (options) => (source) =>
source.pipe(
map((data) => {
return data.map((frame) => rowsToFields(options, frame));
})
),
};
export function rowsToFields(options: RowToFieldsTransformOptions, data: DataFrame): DataFrame {
const mappingResult = evaluteFieldMappings(data, options.mappings ?? [], true);
const { nameField, valueField } = mappingResult;
if (!nameField || !valueField) {
return data;
}
const outFields: Field[] = [];
for (let index = 0; index < nameField.values.length; index++) {
const name = nameField.values.get(index);
const value = valueField.values.get(index);
const config = getFieldConfigFromFrame(data, index, mappingResult);
const labels = getLabelsFromRow(data, index, mappingResult);
const field: Field = {
name: name,
type: valueField.type,
values: new ArrayVector([value]),
config: config,
labels,
};
outFields.push(field);
}
return {
fields: outFields,
length: 1,
};
}
function getLabelsFromRow(frame: DataFrame, index: number, mappingResult: EvaluatedMappingResult): Labels {
const labels = { ...mappingResult.nameField!.labels };
for (let i = 0; i < frame.fields.length; i++) {
const field = frame.fields[i];
const fieldName = getFieldDisplayName(field, frame);
const fieldMapping = mappingResult.index[fieldName];
if (fieldMapping.handler && fieldMapping.handler.key !== FieldConfigHandlerKey.Label) {
continue;
}
const value = field.values.get(index);
if (value != null) {
labels[fieldName] = value;
}
}
return labels;
}

View File

@ -0,0 +1,24 @@
import { DataFrame, getFieldDisplayName } from '@grafana/data';
import { useMemo } from 'react';
export function useAllFieldNamesFromDataFrames(input: DataFrame[]): string[] {
return useMemo(() => {
if (!Array.isArray(input)) {
return [];
}
return Object.keys(
input.reduce((names, frame) => {
if (!frame || !Array.isArray(frame.fields)) {
return names;
}
return frame.fields.reduce((names, field) => {
const t = getFieldDisplayName(field, frame, input);
names[t] = true;
return names;
}, names);
}, {} as Record<string, boolean>)
);
}, [input]);
}

View File

@ -14,6 +14,8 @@ import { seriesToRowsTransformerRegistryItem } from '../components/TransformersU
import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
import { rowsToFieldsTransformRegistryItem } from '../components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor';
import { configFromQueryTransformRegistryItem } from '../components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor';
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
return [
@ -32,5 +34,7 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
sortByTransformRegistryItem,
mergeTransformerRegistryItem,
histogramTransformRegistryItem,
rowsToFieldsTransformRegistryItem,
configFromQueryTransformRegistryItem,
];
};

View File

@ -187,6 +187,7 @@ export function getFieldOverrideCategories(props: OptionPaneRenderProps): Option
render: function renderAddPropertyButton() {
return (
<ValuePicker
key="Add override property"
label="Add override property"
variant="secondary"
isFullWidth={true}

View File

@ -13,15 +13,7 @@ export const PluginStateInfo: FC<Props> = (props) => {
return null;
}
return (
<Badge
color={display.color}
title={display.tooltip}
text={display.text}
icon={display.icon}
tooltip={display.tooltip}
/>
);
return <Badge color={display.color} title={display.tooltip} text={display.text} icon={display.icon} />;
};
function getFeatureStateInfo(state?: PluginState): BadgeProps | null {

View File

@ -287,7 +287,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
return _dir === 1 ? splits : splits.reverse();
};
const xValues: Axis.Values = (u) => u.data[0];
const xValues: Axis.Values = (u) => u.data[0].map((x) => formatValue(0, x));
let hovered: Rect | undefined = undefined;