mirror of https://github.com/grafana/grafana
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>pull/36702/head
parent
e06335ffe9
commit
702fd1cad9
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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', |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
`,
|
||||
}); |
||||
@ -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", |
||||
}, |
||||
] |
||||
`);
|
||||
}); |
||||
}); |
||||
@ -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))), |
||||
}; |
||||
@ -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' }]) |
||||
); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
`,
|
||||
}); |
||||
@ -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; |
||||
} |
||||
@ -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' }], |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
@ -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 |
||||
|
||||
`,
|
||||
}; |
||||
@ -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); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
} |
||||
@ -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]); |
||||
} |
||||
Loading…
Reference in new issue