DataLinks: enable access to labels & field names (#18918)

* POC: trying to see if there is a way to support objects in template interpolations

* Added support for nested objects, and arrays

* Added accessor cache

* fixed unit tests

* First take

* Use links supplier in graph

* Add field's index to cache items

* Get field index from field cache

* CHange FiledCacheItem to FieldWithIndex

* Add refId to TimeSeries class

* Make field link supplier work with _series, _field and _value vars

* use field link supplier in graph

* Fix yaxis settings

* Update dashboard schema version and add migration for data links variables

* Update snapshots

* Update build in data link variables

* FieldCache - idx -> index

* Add current query results to panel editor

* WIP Updated data links dropdown to display new variables

* Fix build

* Update variables syntac in field display, update migration

* Field links supplier: review updates

* Add data frame view and field name to TimeSeries for later inspection

* Retrieve data frame from TimeSeries when clicking on plot graph

* Use data frame's index instead of view

* Retrieve data frame by index instead of view on TimeSeries

* Update data links prism regex

* Fix typecheck

* Add value variables to suggestions list

* UI update

* Rename field to config in DisplayProcessorOptions

* Proces single value of a field instead of entire data frame

* Updated font size from 10px to 12px for auto complete

* Replace fieldName with fieldIndex in TimeSeries

* Don't use .entries() for iterating in field cache

* Don't use FieldCache when retrieving field for datalinks in graph

* Add value calculation variable to data links (#19031)

* Add support for labels with dots in the name (#19033)

* Docs update

* Use field name instead of removed series.fieldName

* Add test dashboard

* Typos fix

* Make visualization tab subscribe to query results

* Added tags to dashboard so it shows up in lists

* minor docs fix

* Update singlestat-ish variables suggestions to contain series variables

* Decrease suggestions update debounce

* Enable whitespace characters(new line, space) in links and strip them when processing the data link

* minor data links UI update

* DataLinks: Add __from and __to variables suggestions to data links (#19093)

* Add from and to variables suggestions to data links

* Update docs

* UI update and added info text

* Change ESC global bind to bind (doesn't capture ESC on input)

* Close datalinks suggestions on ESC

* Remove unnecessary fragment
This commit is contained in:
Torkel Ödegaard 2019-09-13 16:38:21 +02:00 committed by GitHub
parent fc10bd7b8e
commit fd21e0ba14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1175 additions and 272 deletions

View File

@ -0,0 +1,510 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"iteration": 1568372030444,
"links": [],
"panels": [
{
"content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** <span style=\"color: orange;\">$seriesName</span>\n2. **label.datacenter:** <span style=\"color: orange;\">$labelDatacenter</span>\n3. **label.datacenter.region:** <span style=\"color: orange;\">$labelDatacenterRegion</span>\n\n#### Field variables\n1. **Name:** <span style=\"color: orange;\">$fieldName</span>\n\n#### Value variables\n1. **Time:** <span style=\"color: orange;\">$valueTime</span>\n2. **Numeric:** <span style=\"color: orange;\">$valueNumeric</span>\n3. **Text:** <span style=\"color: orange;\">$valueText</span>\n4. **Calc:** <span style=\"color: orange;\">$valueCalc</span>\n\n",
"gridPos": {
"h": 16,
"w": 6,
"x": 0,
"y": 0
},
"id": 8,
"mode": "markdown",
"options": {},
"timeFrom": null,
"timeShift": null,
"title": "",
"transparent": true,
"type": "text"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 9,
"x": 6,
"y": 0
},
"id": 2,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": [
{
"targetBlank": false,
"title": "Drill it down",
"url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-seriesName=${__series.name}&var-labelDatacenter=${__series.labels.datacenter}&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}&var-valueTime=${__value.time}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}"
}
]
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"alias": "Foo datacenter",
"labels": "datacenter=foo,datacenter.region=us-east-1",
"refId": "A",
"scenarioId": "random_walk"
},
{
"alias": "Bar datacenter",
"labels": "datacenter=bar,datacenter.region=us-east-2",
"refId": "B",
"scenarioId": "random_walk"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Multiple series",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 9,
"x": 15,
"y": 0
},
"id": 9,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": [
{
"targetBlank": false,
"title": "Drill it down",
"url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-seriesName=${__series.name}&var-valueTime=${__value.time}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}&var-fieldName=${__field.name}"
}
]
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"alias": "Foo datacenter",
"labels": "datacenter=foo,datacenter.region=us-east-1",
"refId": "A",
"scenarioId": "random_walk_table",
"stringInput": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Multiple fields",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"cacheTimeout": null,
"datasource": "-- Dashboard --",
"gridPos": {
"h": 8,
"w": 9,
"x": 6,
"y": 8
},
"id": 6,
"links": [],
"options": {
"displayMode": "lcd",
"fieldOptions": {
"calcs": ["last"],
"defaults": {
"links": [
{
"targetBlank": true,
"title": "Drill it down!",
"url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source\n?var-fieldName=${__field.name}\n&var-labelDatacenter=${__series.labels.datacenter}\n&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}\n&var-valueNumeric=${__value.numeric}\n&var-valueText=${__value.text}\n&var-valueCalc=${__value.calc}"
}
],
"mappings": [
{
"id": 0,
"op": "=",
"text": "N/A",
"type": 1,
"value": "null"
}
],
"max": 100,
"min": 0,
"nullValueMode": "connected",
"thresholds": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
],
"title": "${__series.name} - $__calc",
"unit": "none"
},
"override": {},
"values": false
},
"orientation": "horizontal"
},
"pluginVersion": "6.4.0-pre",
"targets": [
{
"panelId": 2,
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Value reducers 1",
"type": "bargauge"
},
{
"datasource": "-- Dashboard --",
"gridPos": {
"h": 8,
"w": 9,
"x": 15,
"y": 8
},
"id": 4,
"options": {
"fieldOptions": {
"calcs": ["mean"],
"defaults": {
"links": [
{
"title": "Drill it down",
"url": "http://localhost:3000/d/wfTJJL5Wz/datalinks-source?var-fieldName=${__field.name}&var-labelDatacenter=${__series.labels.datacenter}&var-labelDatacenterRegion=${__series.labels[\"datacenter.region\"]}&var-valueNumeric=${__value.numeric}&var-valueText=${__value.text}&var-valueCalc=${__value.calc}"
}
],
"mappings": [],
"max": 100,
"min": 0,
"thresholds": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
],
"title": "${__series.name} - $__calc"
},
"override": {},
"values": false
},
"orientation": "auto",
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "6.4.0-pre",
"targets": [
{
"panelId": 2,
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Value reducers 2",
"type": "gauge"
}
],
"schemaVersion": 20,
"style": "dark",
"tags": ["gdev", "templating"],
"templating": {
"list": [
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": "Series name",
"name": "seriesName",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "labelDatacenter",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "labelDatacenterRegion",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "valueTime",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "valueNumeric",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "valueText",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "valueCalc",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"text": "",
"value": ""
},
"hide": 2,
"label": null,
"name": "fieldName",
"options": [
{
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datalinks - variables",
"uid": "wfTJJL5Wz",
"version": 1
}

View File

@ -192,7 +192,7 @@ Panel time overrides & timeshift are described in more detail [here]({{< relref
> Only available in Grafana v6.3+.
Data link in graph settings allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL.
Data link allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL.
{{< docs-imagebox img="/img/docs/data_link.png" max-width= "800px" >}}
@ -208,14 +208,40 @@ available suggestions:
{{< docs-imagebox img="/img/docs/data_link_typeahead.png" max-width= "800px" >}}
Available built-in variables are:
#### Built-in variables
1. ``__all_variables`` - will add all current dashboard's variables to the URL
2. ``__url_time_range`` - will add current dashboard's time range to the URL (i.e. ``?from=now-6h&to=now``)
3. ``__series_name`` - will add series name as a query param in the URL (i.e. ``?series=B-series``)
4. ``__value_time`` - will add datapoint's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``)
``__url_time_range`` - current dashboard's time range (i.e. ``?from=now-6h&to=now``)
``__from`` - current dashboard's time range from value
``__to`` - current dashboard's time range to value
#### Series variables
Series specific variables are available under ``__series`` namespace:
``__series.name`` - series name to the URL
``__series.labels.<LABEL>`` - label's value to the URL. If your label contains dots use ``__series.labels["<LABEL>"]`` syntax
#### Field variables
Field specific variables are available under ``__field`` namespace:
``__field.name`` - field name to the URL
#### Value variables
Value specific variables are available under ``__value`` namespace:
``__value.time`` - value's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``)
``__value.raw`` - raw value
``__value.numeric`` - numeric representation of a value
``__value.text`` - text representation of a value
``__value.calc`` - calculation name if the value is result of calculation
#### Template variables in data links
#### Template variables
When linking to another dashboard that uses template variables, you can use ``var-myvar=${myvar}`` syntax (where ``myvar`` is a name of template variable)
to use current dashboard's variable value.
to use current dashboard's variable value. If you want to add all of the current dashboard's variables to the URL use ``__all_variables`` variable.

View File

@ -20,7 +20,8 @@ export class FieldCache {
index: idx,
}));
for (const [index, field] of data.fields.entries()) {
for (let i = 0; i < data.fields.length; i++) {
const field = data.fields[i];
// Make sure it has a type
if (field.type === FieldType.other) {
const t = guessFieldTypeForField(field);
@ -33,13 +34,13 @@ export class FieldCache {
}
this.fieldByType[field.type].push({
...field,
index,
index: i,
});
if (this.fieldByName[field.name]) {
console.warn('Duplicate field names in DataFrame: ', field.name);
} else {
this.fieldByName[field.name] = { ...field, index };
this.fieldByName[field.name] = { ...field, index: i };
}
}
}

View File

@ -2,12 +2,13 @@ import React, { useState, ChangeEvent, useContext } from 'react';
import { DataLink } from '@grafana/data';
import { FormField, Switch } from '../index';
import { VariableSuggestion } from './DataLinkSuggestions';
import { css, cx } from 'emotion';
import { css } from 'emotion';
import { ThemeContext } from '../../themes/index';
import { DataLinkInput } from './DataLinkInput';
interface DataLinkEditorProps {
index: number;
isLast: boolean;
value: DataLink;
suggestions: VariableSuggestion[];
onChange: (index: number, link: DataLink) => void;
@ -15,7 +16,7 @@ interface DataLinkEditorProps {
}
export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
({ index, value, onChange, onRemove, suggestions }) => {
({ index, value, onChange, onRemove, suggestions, isLast }) => {
const theme = useContext(ThemeContext);
const [title, setTitle] = useState(value.title);
@ -38,46 +39,48 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
onChange(index, { ...value, targetBlank: !value.targetBlank });
};
return (
<div
className={cx(
'gf-form gf-form--inline',
css`
> * {
margin-right: ${theme.spacing.xs};
&:last-child {
margin-right: 0;
}
}
`
)}
>
<FormField
label="Title"
value={title}
onChange={onTitleChange}
onBlur={onTitleBlur}
inputWidth={15}
labelWidth={5}
placeholder="Show details"
/>
const listItemStyle = css`
margin-bottom: ${theme.spacing.sm};
`;
const infoTextStyle = css`
padding-bottom: ${theme.spacing.md};
margin-left: 66px;
color: ${theme.colors.textWeak};
`;
return (
<div className={listItemStyle}>
<div className="gf-form gf-form--inline">
<FormField
className="gf-form--grow"
label="Title"
value={title}
onChange={onTitleChange}
onBlur={onTitleBlur}
inputWidth={0}
labelWidth={5}
placeholder="Show details"
/>
<Switch label="Open in new tab" checked={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
<button className="gf-form-label gf-form-label--btn" onClick={onRemoveClick} title="Remove link">
<i className="fa fa-times" />
</button>
</div>
<FormField
label="URL"
labelWidth={4}
labelWidth={5}
inputEl={<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />}
className={css`
width: 100%;
`}
/>
<Switch label="Open in new tab" checked={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
<div className="gf-form">
<button className="gf-form-label gf-form-label--btn" onClick={onRemoveClick}>
<i className="fa fa-times" />
</button>
</div>
{isLast && (
<div className={infoTextStyle}>
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
CTRL+Space, or $ to open variable suggestions.
</div>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import React, { useState, useMemo, useCallback, useContext } from 'react';
import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions';
import { makeValue, ThemeContext } from '../../index';
import { makeValue, ThemeContext, DataLinkBuiltInVars } from '../../index';
import { SelectionReference } from './SelectionReference';
import { Portal } from '../index';
// @ts-ignore
@ -77,10 +77,10 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
}
};
useDebounce(updateUsedSuggestions, 500, [linkUrl]);
useDebounce(updateUsedSuggestions, 250, [linkUrl]);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Backspace') {
if (event.key === 'Backspace' || event.key === 'Escape') {
setShowingSuggestions(false);
setSuggestionsIndex(0);
}
@ -111,7 +111,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
setShowingSuggestions(true);
}
if (event.key === 'Enter') {
if (event.key === 'Enter' && showingSuggestions) {
// Preventing entering a new line
// As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289
return false;
@ -134,7 +134,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
const change = linkUrl.change();
if (item.origin === VariableOrigin.BuiltIn) {
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
change.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
} else {
change.insertText(`var-${item.value}=$\{${item.value}}`);
@ -167,7 +167,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
modifiers={{
preventOverflow: { enabled: true, boundariesElement: 'window' },
arrow: { enabled: false },
offset: { offset: 200 }, // width of the suggestions menu
offset: { offset: 250 }, // width of the suggestions menu
}}
>
{({ ref, style, placement }) => {

View File

@ -1,16 +1,22 @@
import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
import { css, cx } from 'emotion';
import _ from 'lodash';
import React, { useRef, useContext, useMemo } from 'react';
import useClickAway from 'react-use/lib/useClickAway';
import { List } from '../index';
import tinycolor from 'tinycolor2';
export enum VariableOrigin {
BuiltIn = 'builtin',
Series = 'series',
Field = 'field',
Value = 'value',
BuiltIn = 'built-in',
Template = 'template',
}
export interface VariableSuggestion {
value: string;
label: string;
documentation?: string;
origin: VariableOrigin;
}
@ -71,16 +77,34 @@ const getStyles = (theme: GrafanaTheme) => {
theme.type
);
const separatorColor = selectThemeVariant(
{
light: tinycolor(wrapperBg.toString())
.darken(10)
.toString(),
dark: tinycolor(wrapperBg.toString())
.lighten(10)
.toString(),
},
theme.type
);
return {
list: css`
border-bottom: 1px solid ${separatorColor};
&:last-child {
border: none;
}
`,
wrapper: css`
background: ${wrapperBg};
z-index: 1;
width: 200px;
width: 250px;
box-shadow: 0 5px 10px 0 ${wrapperShadow};
`,
item: css`
background: none;
padding: 4px 8px;
padding: 2px 8px;
color: ${itemColor};
cursor: pointer;
&:hover {
@ -89,9 +113,6 @@ const getStyles = (theme: GrafanaTheme) => {
`,
label: css`
color: ${theme.colors.textWeak};
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.lineHeight.lg};
padding: ${theme.spacing.sm};
`,
activeItem: css`
background: ${itemBgActive};
@ -101,11 +122,11 @@ const getStyles = (theme: GrafanaTheme) => {
`,
itemValue: css`
font-family: ${theme.typography.fontFamily.monospace};
font-size: ${theme.typography.size.sm};
`,
itemDocs: css`
margin-top: ${theme.spacing.xs};
color: ${itemDocsColor};
font-size: ${theme.typography.size.sm};
`,
};
};
@ -119,34 +140,35 @@ export const DataLinkSuggestions: React.FC<DataLinkSuggestionsProps> = ({ sugges
}
});
const templateSuggestions = useMemo(() => {
return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.Template);
}, [suggestions]);
const builtInSuggestions = useMemo(() => {
return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.BuiltIn);
const groupedSuggestions = useMemo(() => {
return _.groupBy(suggestions, s => s.origin);
}, [suggestions]);
const styles = getStyles(theme);
return (
<div ref={ref} className={styles.wrapper}>
{templateSuggestions.length > 0 && (
<DataLinkSuggestionsList
{...otherProps}
suggestions={templateSuggestions}
label="Template variables"
activeIndex={otherProps.activeIndex}
activeIndexOffset={0}
/>
)}
{builtInSuggestions.length > 0 && (
<DataLinkSuggestionsList
{...otherProps}
suggestions={builtInSuggestions}
label="Built-in variables"
activeIndexOffset={templateSuggestions.length}
/>
)}
{Object.keys(groupedSuggestions).map((key, i) => {
const indexOffset =
i === 0
? 0
: Object.keys(groupedSuggestions).reduce((acc, current, index) => {
if (index >= i) {
return acc;
}
return acc + groupedSuggestions[current].length;
}, 0);
return (
<DataLinkSuggestionsList
{...otherProps}
suggestions={groupedSuggestions[key]}
label={`${_.capitalize(key)}`}
activeIndex={otherProps.activeIndex}
activeIndexOffset={indexOffset}
key={key}
/>
);
})}
</div>
);
};
@ -165,8 +187,8 @@ const DataLinkSuggestionsList: React.FC<DataLinkSuggestionsListProps> = React.me
return (
<>
<div className={styles.label}>{label}</div>
<List
className={styles.list}
items={suggestions}
renderItem={(item, index) => {
return (
@ -175,9 +197,11 @@ const DataLinkSuggestionsList: React.FC<DataLinkSuggestionsListProps> = React.me
onClick={() => {
onSuggestionSelect(item);
}}
title={item.documentation}
>
<div className={styles.itemValue}>{item.value}</div>
{item.documentation && <div className={styles.itemDocs}>{item.documentation}</div>}
<span className={styles.itemValue}>
<span className={styles.label}>{label}</span> {item.label}
</span>
</div>
);
}}

View File

@ -19,7 +19,7 @@ interface DataLinksEditorProps {
Prism.languages['links'] = {
builtInVariable: {
pattern: /(\${\w+})/,
pattern: /(\${\S+?})/,
},
};
@ -57,6 +57,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
<DataLinkEditor
key={index.toString()}
index={index}
isLast={index === value.length - 1}
value={link}
onChange={onLinkChanged}
onRemove={onRemove}

View File

@ -2,6 +2,7 @@ import React, { InputHTMLAttributes, FunctionComponent } from 'react';
import { FormLabel } from '../FormLabel/FormLabel';
import { PopoverContent } from '../Tooltip/Tooltip';
import { cx } from 'emotion';
export interface Props extends InputHTMLAttributes<HTMLInputElement> {
label: string;
tooltip?: PopoverContent;
@ -33,7 +34,9 @@ export const FormField: FunctionComponent<Props> = ({
<FormLabel width={labelWidth} tooltip={tooltip}>
{label}
</FormLabel>
{inputEl || <input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} />}
{inputEl || (
<input type="text" className={`gf-form-input ${inputWidth ? `width-${inputWidth}` : ''}`} {...inputProps} />
)}
</div>
);
};

View File

@ -36,10 +36,10 @@ export class AbstractList<T> extends React.PureComponent<AbstractListProps<T>> {
}
render() {
const { items, renderItem, getItemKey } = this.props;
const { items, renderItem, getItemKey, className } = this.props;
const styles = this.getListStyles();
return (
<ul className={styles.list}>
<ul className={cx(styles.list, className)}>
{items.map((item, i) => {
return (
<li className={styles.item} key={getItemKey ? getItemKey(item) : i}>

View File

@ -70,9 +70,9 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
<div>
Template Variables:
<br />
{'$' + VAR_SERIES_NAME}
{'${' + VAR_SERIES_NAME + '}'}
<br />
{'$' + VAR_FIELD_NAME}
{'${' + VAR_FIELD_NAME + '}'}
<br />
{'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
</div>

View File

@ -35,6 +35,7 @@ export interface PanelProps<T = any> {
export interface PanelEditorProps<T = any> {
options: T;
onOptionsChange: (options: T) => void;
data: PanelData;
}
export interface PanelModel<TOptions = any> {

View File

@ -3,9 +3,17 @@ import { LinkModelSupplier } from '@grafana/data';
export const DataLinkBuiltInVars = {
keepTime: '__url_time_range',
timeRangeFrom: '__from',
timeRangeTo: '__to',
includeVars: '__all_variables',
seriesName: '__series_name',
valueTime: '__value_time',
seriesName: '__series.name',
fieldName: '__field.name',
valueTime: '__value.time',
valueNumeric: '__value.numeric',
valueText: '__value.text',
valueRaw: '__value.raw',
// name of the calculation represented by the value
valueCalc: '__value.calc',
};
/**

View File

@ -18,10 +18,10 @@ describe('Process simple display values', () => {
getDisplayProcessor(),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ field: { min: 0, max: 100 } }),
getDisplayProcessor({ config: { min: 0, max: 100 } }),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ field: { unit: 'locale' } }),
getDisplayProcessor({ config: { unit: 'locale' } }),
];
it('support null', () => {
@ -102,7 +102,7 @@ describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
const value = 'N/A';
const instance = getDisplayProcessor({ field: { mappings: valueMappings } });
const instance = getDisplayProcessor({ config: { mappings: valueMappings } });
const result = instance(value);
@ -113,7 +113,7 @@ describe('Format value', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } });
const result = instance(value);
@ -126,7 +126,7 @@ describe('Format value', () => {
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } });
const result = instance(value);
@ -135,20 +135,20 @@ describe('Format value', () => {
it('should set auto decimals, 1 significant', () => {
const value = 3.23;
const instance = getDisplayProcessor({ field: { decimals: null } });
const instance = getDisplayProcessor({ config: { decimals: null } });
expect(instance(value).text).toEqual('3.2');
});
it('should set auto decimals, 2 significant', () => {
const value = 0.0245;
const instance = getDisplayProcessor({ field: { decimals: null } });
const instance = getDisplayProcessor({ config: { decimals: null } });
expect(instance(value).text).toEqual('0.025');
});
it('should use override decimals', () => {
const value = 100030303;
const instance = getDisplayProcessor({ field: { decimals: 2, unit: 'bytes' } });
const instance = getDisplayProcessor({ config: { decimals: 2, unit: 'bytes' } });
expect(instance(value).text).toEqual('95.40 MiB');
});
@ -158,7 +158,7 @@ describe('Format value', () => {
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } });
const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } });
expect(instance(value).text).toEqual('1-20');
});
@ -169,25 +169,25 @@ describe('Format value', () => {
it('with value 1000 and unit short', () => {
const value = 1000;
const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } });
const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } });
expect(instance(value).text).toEqual('1.000 K');
});
it('with value 1200 and unit short', () => {
const value = 1200;
const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } });
const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } });
expect(instance(value).text).toEqual('1.200 K');
});
it('with value 1250 and unit short', () => {
const value = 1250;
const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } });
const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } });
expect(instance(value).text).toEqual('1.250 K');
});
it('with value 10000000 and unit short', () => {
const value = 1000000;
const instance = getDisplayProcessor({ field: { decimals: null, unit: 'short' } });
const instance = getDisplayProcessor({ config: { decimals: null, unit: 'short' } });
expect(instance(value).text).toEqual('1.000 Mil');
});
});

View File

@ -18,7 +18,7 @@ import { getColorFromHexRgbOrName } from './namedColorsPalette';
import { GrafanaTheme, GrafanaThemeType } from '../types/index';
interface DisplayProcessorOptions {
field?: FieldConfig;
config?: FieldConfig;
// Context
isUtc?: boolean;
@ -27,7 +27,7 @@ interface DisplayProcessorOptions {
export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayProcessor {
if (options && !_.isEmpty(options)) {
const field = options.field ? options.field : {};
const field = options.config ? options.config : {};
const formatFunc = getValueFormat(field.unit || 'none');
return (value: any) => {

View File

@ -17,7 +17,6 @@ import toString from 'lodash/toString';
import { GrafanaTheme, InterpolateFunction } from '../types/index';
import { getDisplayProcessor } from './displayProcessor';
import { getFlotPairs } from './flotPairs';
import { DataLinkBuiltInVars } from '../utils/dataLinks';
export interface FieldDisplayOptions {
values?: boolean; // If true show each row value
@ -28,8 +27,8 @@ export interface FieldDisplayOptions {
override: FieldConfig; // Set these values regardless of the source
}
// TODO: use built in variables, same as for data links?
export const VAR_SERIES_NAME = '__series_name';
export const VAR_FIELD_NAME = '__field_name';
export const VAR_SERIES_NAME = '__series.name';
export const VAR_FIELD_NAME = '__field.name';
export const VAR_CALC = '__calc';
export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates
@ -54,7 +53,7 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat
parts.push('$' + VAR_CALC);
}
if (data.length > 1) {
parts.push('$' + VAR_SERIES_NAME);
parts.push('${' + VAR_SERIES_NAME + '}');
}
if (fieldCount > 1 || !parts.length) {
parts.push('$' + VAR_FIELD_NAME);
@ -70,8 +69,8 @@ export interface FieldDisplay {
// Expose to the original values for delayed inspection (DataLinks etc)
view?: DataFrameView;
column?: number; // The field column index
row?: number; // only filled in when the value is from a row (ie, not a reduction)
colIndex?: number; // The field column index
rowIndex?: number; // only filled in when the value is from a row (ie, not a reduction)
}
export interface GetFieldDisplayValuesOptions {
@ -106,7 +105,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
};
}
scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name };
scopedVars['__series'] = { text: 'Series', value: { name: series.name } };
const { timeField } = getTimeField(series);
const view = new DataFrameView(series);
@ -125,15 +124,14 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name = `Field[${s}]`;
}
scopedVars[VAR_FIELD_NAME] = { text: 'Field', value: name };
scopedVars['__field'] = { text: 'Field', value: { name } };
const display = getDisplayProcessor({
field: config,
config,
theme: options.theme,
});
const title = config.title ? config.title : defaultTitle;
// Show all rows
if (fieldOptions.values) {
const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0;
@ -158,8 +156,8 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
field: config,
display: displayValue,
view,
column: i,
row: j,
colIndex: i,
rowIndex: j,
});
if (values.length >= limit) {
@ -187,12 +185,12 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
const displayValue = display(results[calc]);
displayValue.title = replaceVariables(title, scopedVars);
values.push({
name,
name: calc,
field: config,
display: displayValue,
sparkline,
view,
column: i,
colIndex: i,
});
}
}

View File

@ -246,7 +246,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel {
hasUniqueLabels = true;
}
const timeFieldIndex = fieldCache.getFirstFieldOfType(FieldType.time);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
const stringField = fieldCache.getFirstFieldOfType(FieldType.string);
const logLevelField = fieldCache.getFieldByName('level');
@ -256,7 +256,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel {
}
for (let j = 0; j < series.length; j++) {
const ts = timeFieldIndex.values.get(j);
const ts = timeField.values.get(j);
const time = dateTime(ts);
const timeEpochMs = time.valueOf();
const timeFromNow = time.fromNow();

View File

@ -46,7 +46,7 @@ export class KeybindingSrv {
this.bind('g p', this.goToProfile);
this.bind('s o', this.openSearch);
this.bind('f', this.openSearch);
this.bindGlobal('esc', this.exit);
this.bind('esc', this.exit);
}
}

View File

@ -78,7 +78,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -191,7 +191,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -315,7 +315,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -426,7 +426,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -521,7 +521,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],

View File

@ -232,7 +232,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -469,7 +469,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -706,7 +706,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -943,7 +943,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 19,
"schemaVersion": 20,
"snapshot": undefined,
"style": "dark",
"tags": Array [],

View File

@ -18,8 +18,10 @@ import { PanelModel } from '../state';
import { DashboardModel } from '../state';
import { VizPickerSearch } from './VizPickerSearch';
import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
import { PanelPlugin, PanelPluginMeta } from '@grafana/ui';
import { PanelPlugin, PanelPluginMeta, PanelData } from '@grafana/ui';
import { PanelCtrl } from 'app/plugins/sdk';
import { Unsubscribable } from 'rxjs';
import { LoadingState } from '@grafana/data';
interface Props {
panel: PanelModel;
@ -36,11 +38,13 @@ interface State {
searchQuery: string;
scrollTop: number;
hasBeenFocused: boolean;
data: PanelData;
}
export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement;
angularOptions: AngularComponent;
querySubscription: Unsubscribable;
constructor(props: Props) {
super(props);
@ -50,6 +54,10 @@ export class VisualizationTab extends PureComponent<Props, State> {
hasBeenFocused: false,
searchQuery: '',
scrollTop: 0,
data: {
state: LoadingState.NotStarted,
series: [],
},
};
}
@ -66,16 +74,28 @@ export class VisualizationTab extends PureComponent<Props, State> {
}
if (plugin.editor) {
return <plugin.editor options={this.getReactPanelOptions()} onOptionsChange={this.onPanelOptionsChanged} />;
return (
<plugin.editor
data={this.state.data}
options={this.getReactPanelOptions()}
onOptionsChange={this.onPanelOptionsChanged}
/>
);
}
return <p>Visualization has no options</p>;
}
componentDidMount() {
const { panel } = this.props;
const queryRunner = panel.getQueryRunner();
if (this.shouldLoadAngularOptions()) {
this.loadAngularOptions();
}
this.querySubscription = queryRunner.getData().subscribe({
next: (data: PanelData) => this.setState({ data }),
});
}
componentDidUpdate(prevProps: Props) {

View File

@ -128,7 +128,7 @@ describe('DashboardModel', () => {
});
it('dashboard schema version should be set to latest', () => {
expect(model.schemaVersion).toBe(19);
expect(model.schemaVersion).toBe(20);
});
it('graph thresholds should be migrated', () => {
@ -441,6 +441,71 @@ describe('DashboardModel', () => {
expect(model.panels[0].links[3].url).toBe(`/dashboard/db/my-other-dashboard`);
});
});
describe('when migrating variables', () => {
let model: any;
beforeEach(() => {
model = new DashboardModel({
panels: [
{
//graph panel
options: {
dataLinks: [
{
url: 'http://mylink.com?series=${__series_name}',
},
{
url: 'http://mylink.com?series=${__value_time}',
},
],
},
},
{
// panel with field options
options: {
fieldOptions: {
defaults: {
links: [
{
url: 'http://mylink.com?series=${__series_name}',
},
{
url: 'http://mylink.com?series=${__value_time}',
},
],
title: '$__cell_0 * $__field_name * $__series_name',
},
},
},
},
],
});
});
describe('data links', () => {
it('should replace __series_name variable with __series.name', () => {
expect(model.panels[0].options.dataLinks[0].url).toBe('http://mylink.com?series=${__series.name}');
expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe(
'http://mylink.com?series=${__series.name}'
);
});
it('should replace __value_time variable with __value.time', () => {
expect(model.panels[0].options.dataLinks[1].url).toBe('http://mylink.com?series=${__value.time}');
expect(model.panels[1].options.fieldOptions.defaults.links[1].url).toBe(
'http://mylink.com?series=${__value.time}'
);
});
});
describe('field display', () => {
it('should replace __series_name and __field_name variables with new syntax', () => {
expect(model.panels[1].options.fieldOptions.defaults.title).toBe(
'$__cell_0 * ${__field.name} * ${__series.name}'
);
});
});
});
});
function createRow(options: any, panelDescriptions: any[]) {

View File

@ -33,7 +33,7 @@ export class DashboardMigrator {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = [];
this.dashboard.schemaVersion = 19;
this.dashboard.schemaVersion = 20;
if (oldVersion === this.dashboard.schemaVersion) {
return;
@ -436,6 +436,33 @@ export class DashboardMigrator {
});
}
if (oldVersion < 20) {
const updateLinks = (link: DataLink) => {
return {
...link,
url: updateVariablesSyntax(link.url),
};
};
panelUpgrades.push((panel: any) => {
// For graph panel
if (panel.options && panel.options.dataLinks && _.isArray(panel.options.dataLinks)) {
panel.options.dataLinks = panel.options.dataLinks.map(updateLinks);
}
// For panel with fieldOptions
if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) {
if (panel.options.fieldOptions.defaults.links && _.isArray(panel.options.fieldOptions.defaults.links)) {
panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks);
}
if (panel.options.fieldOptions.defaults.title) {
panel.options.fieldOptions.defaults.title = updateVariablesSyntax(
panel.options.fieldOptions.defaults.title
);
}
}
});
}
if (panelUpgrades.length === 0) {
return;
}
@ -666,3 +693,26 @@ function upgradePanelLink(link: any): DataLink {
targetBlank: link.targetBlank,
};
}
function updateVariablesSyntax(text: string) {
const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g;
return text.replace(legacyVariableNamesRegex, (match, seriesName, seriesName1, valueTime, fieldName, fieldName1) => {
if (seriesName) {
return '__series.name';
}
if (seriesName1) {
return '${__series.name}';
}
if (valueTime) {
return '__value.time';
}
if (fieldName) {
return '__field.name';
}
if (fieldName1) {
return '${__field.name}';
}
return match;
});
}

View File

@ -1,8 +1,32 @@
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { FieldDisplay, DataLinkBuiltInVars } from '@grafana/ui';
import { LinkModelSupplier, getTimeField, ScopedVars } from '@grafana/data';
import { FieldDisplay } from '@grafana/ui';
import { LinkModelSupplier, getTimeField, Labels, ScopedVars, ScopedVar } from '@grafana/data';
import { getLinkSrv } from './link_srv';
interface SeriesVars {
name?: string;
labels?: Labels;
refId?: string;
}
interface FieldVars {
name: string;
}
interface ValueVars {
raw: any;
numeric: number;
text: string;
time?: number;
calc?: string;
}
interface DataLinkScopedVars extends ScopedVars {
__series?: ScopedVar<SeriesVars>;
__field?: ScopedVar<FieldVars>;
__value?: ScopedVar<ValueVars>;
}
/**
* Link suppliers creates link models based on a link origin
*/
@ -14,29 +38,53 @@ export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier<Fi
}
return {
getLinks: (_scopedVars?: any) => {
const scopedVars: ScopedVars = {};
// TODO, add values to scopedVars and/or pass objects to event listeners
const scopedVars: DataLinkScopedVars = {};
if (value.view) {
scopedVars[DataLinkBuiltInVars.seriesName] = {
const { dataFrame } = value.view;
scopedVars['__series'] = {
value: {
name: dataFrame.name,
labels: dataFrame.labels,
refId: dataFrame.refId,
},
text: 'Series',
value: value.view.dataFrame.name,
};
const field = value.column ? value.view.dataFrame.fields[value.column] : undefined;
const field = value.colIndex !== undefined ? dataFrame.fields[value.colIndex] : undefined;
if (field) {
console.log('Full Field Info:', field);
scopedVars['__field'] = {
value: {
name: field.name,
},
text: 'Field',
};
}
if (value.row) {
const row = value.view.get(value.row);
console.log('ROW:', row);
const dataFrame = value.view.dataFrame;
if (value.rowIndex) {
const { timeField } = getTimeField(dataFrame);
if (timeField) {
scopedVars[DataLinkBuiltInVars.valueTime] = {
text: 'Value time',
value: timeField.values.get(value.row),
};
}
scopedVars['__value'] = {
value: {
raw: field.values.get(value.rowIndex),
numeric: value.display.numeric,
text: value.display.text,
time: timeField ? timeField.values.get(value.rowIndex) : undefined,
},
text: 'Value',
};
} else {
// calculation
scopedVars['__value'] = {
value: {
raw: value.display.numeric,
numeric: value.display.numeric,
text: value.display.text,
calc: value.name,
},
text: 'Value',
};
}
} else {
console.log('VALUE', value);

View File

@ -4,47 +4,118 @@ import templateSrv, { TemplateSrv } from 'app/features/templating/template_srv';
import coreModule from 'app/core/core_module';
import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
import { DataLink, KeyValue, deprecationWarning, LinkModel, ScopedVars } from '@grafana/data';
import { DataLink, KeyValue, deprecationWarning, LinkModel, DataFrame, ScopedVars } from '@grafana/data';
const timeRangeVars = [
{
value: `${DataLinkBuiltInVars.keepTime}`,
label: 'Time range',
documentation: 'Adds current time range',
origin: VariableOrigin.BuiltIn,
},
{
value: `${DataLinkBuiltInVars.timeRangeFrom}`,
label: 'Time range: from',
documentation: "Adds current time range's from value",
origin: VariableOrigin.BuiltIn,
},
{
value: `${DataLinkBuiltInVars.timeRangeTo}`,
label: 'Time range: to',
documentation: "Adds current time range's to value",
origin: VariableOrigin.BuiltIn,
},
];
const fieldVars = [
{
value: `${DataLinkBuiltInVars.fieldName}`,
label: 'Name',
documentation: 'Field name of the clicked datapoint (in ms epoch)',
origin: VariableOrigin.Field,
},
];
const valueVars = [
{
value: `${DataLinkBuiltInVars.valueNumeric}`,
label: 'Numeric',
documentation: 'Numeric representation of selected value',
origin: VariableOrigin.Value,
},
{
value: `${DataLinkBuiltInVars.valueText}`,
label: 'Text',
documentation: 'Text representation of selected value',
origin: VariableOrigin.Value,
},
{
value: `${DataLinkBuiltInVars.valueRaw}`,
label: 'Raw',
documentation: 'Raw value',
origin: VariableOrigin.Value,
},
];
const buildLabelPath = (label: string) => {
return label.indexOf('.') > -1 ? `["${label}"]` : `.${label}`;
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
...templateSrv.variables.map(variable => ({
value: variable.name as string,
label: variable.name,
origin: VariableOrigin.Template,
})),
{
value: `${DataLinkBuiltInVars.includeVars}`,
label: 'All variables',
documentation: 'Adds current variables',
origin: VariableOrigin.BuiltIn,
},
{
value: `${DataLinkBuiltInVars.keepTime}`,
documentation: 'Adds current time range',
origin: VariableOrigin.BuiltIn,
origin: VariableOrigin.Template,
},
...timeRangeVars,
];
export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [
...getPanelLinksVariableSuggestions(),
{
value: `${DataLinkBuiltInVars.seriesName}`,
documentation: 'Adds series name',
origin: VariableOrigin.BuiltIn,
},
{
const getSeriesVars = (dataFrames: DataFrame[]) => {
const labels = _.flatten(dataFrames.map(df => Object.keys(df.labels || {})));
return [
{
value: `${DataLinkBuiltInVars.seriesName}`,
label: 'Name',
documentation: 'Name of the series',
origin: VariableOrigin.Series,
},
...labels.map(label => ({
value: `__series.labels${buildLabelPath(label)}`,
label: `labels.${label}`,
documentation: `${label} label value`,
origin: VariableOrigin.Series,
})),
];
};
export const getDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
const seriesVars = getSeriesVars(dataFrames);
const valueTimeVar = {
value: `${DataLinkBuiltInVars.valueTime}`,
label: 'Time',
documentation: 'Time value of the clicked datapoint (in ms epoch)',
origin: VariableOrigin.BuiltIn,
},
];
origin: VariableOrigin.Value,
};
export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [
...getPanelLinksVariableSuggestions(),
{
value: `${DataLinkBuiltInVars.seriesName}`,
documentation: 'Adds series name',
origin: VariableOrigin.BuiltIn,
},
];
return [...seriesVars, ...fieldVars, ...valueVars, valueTimeVar, ...getPanelLinksVariableSuggestions()];
};
export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
const seriesVars = getSeriesVars(dataFrames);
const valueCalcVar = {
value: `${DataLinkBuiltInVars.valueCalc}`,
label: 'Calculation name',
documentation: 'Name of the calculation the value is a result of',
origin: VariableOrigin.Value,
};
return [...seriesVars, ...fieldVars, ...valueVars, valueCalcVar, ...getPanelLinksVariableSuggestions()];
};
export interface LinkService {
getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>;
@ -83,16 +154,15 @@ export class LinkSrv implements LinkService {
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
const info: LinkModel<T> = {
href: link.url,
href: link.url.replace(/\s|\n/g, ''),
title: this.templateSrv.replace(link.title || '', scopedVars),
target: link.targetBlank ? '_blank' : '_self',
origin,
};
this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
const variablesQuery = toUrlParams(params);
info.href = this.templateSrv.replace(link.url, {
info.href = this.templateSrv.replace(info.href, {
...scopedVars,
[DataLinkBuiltInVars.keepTime]: {
text: timeRangeUrl,

View File

@ -105,11 +105,13 @@ describe('linkSrv', () => {
linkSrv.getDataLinkUIModel(
{
title: 'Any title',
url: `/d/1?var-test=$${DataLinkBuiltInVars.seriesName}`,
url: `/d/1?var-test=$\{${DataLinkBuiltInVars.seriesName}}`,
},
{
[DataLinkBuiltInVars.seriesName]: {
value: 'A-series',
__series: {
value: {
name: 'A-series',
},
text: 'A-series',
},
},
@ -122,12 +124,12 @@ describe('linkSrv', () => {
linkSrv.getDataLinkUIModel(
{
title: 'Any title',
url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`,
url: `/d/1?time=$\{${DataLinkBuiltInVars.valueTime}}`,
},
{
[DataLinkBuiltInVars.valueTime]: {
value: dataPointMock.datapoint[0],
text: dataPointMock.datapoint[0],
__value: {
value: { time: dataPointMock.datapoint[0] },
text: 'Value',
},
},
{}

View File

@ -24,6 +24,27 @@ describe('templateSrv', () => {
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
});
it('scoped vars should support objects', () => {
const target = _templateSrv.replace('${series.name} ${series.nested.field}', {
series: { value: { name: 'Server1', nested: { field: 'nested' } } },
});
expect(target).toBe('Server1 nested');
});
it('scoped vars should support objects with propert names with dot', () => {
const target = _templateSrv.replace('${series.name} ${series.nested["field.with.dot"]}', {
series: { value: { name: 'Server1', nested: { 'field.with.dot': 'nested' } } },
});
expect(target).toBe('Server1 nested');
});
it('scoped vars should support arrays of objects', () => {
const target = _templateSrv.replace('${series.rows[0].name} ${series.rows[1].name}', {
series: { value: { rows: [{ name: 'first' }, { name: 'second' }] } },
});
expect(target).toBe('first second');
});
it('should replace $test with scoped value', () => {
const target = _templateSrv.replace('this.$test.filters', {
test: { value: 'mupp', text: 'asd' },

View File

@ -7,6 +7,10 @@ function luceneEscape(value: string) {
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
}
interface FieldAccessorCache {
[key: string]: (obj: any) => any;
}
export class TemplateSrv {
variables: any[];
@ -15,6 +19,7 @@ export class TemplateSrv {
private grafanaVariables: any = {};
private builtIns: any = {};
private timeRange: TimeRange = null;
private fieldAccessorCache: FieldAccessorCache = {};
constructor() {
this.builtIns['__interval'] = { text: '1s', value: '1s' };
@ -224,21 +229,44 @@ export class TemplateSrv {
return values;
}
getFieldAccessor(fieldPath: string) {
const accessor = this.fieldAccessorCache[fieldPath];
if (accessor) {
return accessor;
}
return (this.fieldAccessorCache[fieldPath] = _.property(fieldPath));
}
getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars) {
const scopedVar = scopedVars[variableName];
if (!scopedVar) {
return null;
}
if (fieldPath) {
return this.getFieldAccessor(fieldPath)(scopedVar.value);
}
return scopedVar.value;
}
replace(target: string, scopedVars?: ScopedVars, format?: string | Function): any {
if (!target) {
return target;
}
let variable, systemValue, value, fmt;
this.regex.lastIndex = 0;
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
variable = this.index[var1 || var2 || var3];
fmt = fmt2 || fmt3 || format;
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
const variableName = var1 || var2 || var3;
const variable = this.index[variableName];
const fmt = fmt2 || fmt3 || format;
if (scopedVars) {
value = scopedVars[var1 || var2 || var3];
if (value) {
return this.formatValue(value.value, fmt, variable);
const value = this.getVariableValue(variableName, fieldPath, scopedVars);
if (value !== null && value !== undefined) {
return this.formatValue(value, fmt, variable);
}
}
@ -246,12 +274,12 @@ export class TemplateSrv {
return match;
}
systemValue = this.grafanaVariables[variable.current.value];
const systemValue = this.grafanaVariables[variable.current.value];
if (systemValue) {
return this.formatValue(systemValue, fmt, variable);
}
value = variable.current.value;
let value = variable.current.value;
if (this.isAllValue(value)) {
value = this.getAllValue(variable);
// skip formatting of custom all values

View File

@ -7,7 +7,7 @@ import { assignModelProperties } from 'app/core/utils/model_utils';
* \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
* \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
*/
export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g;
export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::(\w+))?}/g;
// Helper function since lastIndex is not reset
export const variableRegexExec = (variableString: string) => {

View File

@ -68,8 +68,8 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
? getDataLinksVariableSuggestions(this.props.data.series)
: getCalculationValueDataLinksVariableSuggestions(this.props.data.series);
const labelWidth = 6;
return (

View File

@ -72,9 +72,10 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
const { options } = this.props;
const { fieldOptions, showThresholdLabels, showThresholdMarkers } = options;
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
? getDataLinksVariableSuggestions(this.props.data.series)
: getCalculationValueDataLinksVariableSuggestions(this.props.data.series);
return (
<>

View File

@ -12,7 +12,7 @@ export interface FlotDataPoint {
export class GraphContextMenuCtrl {
private source?: FlotDataPoint | null;
private scope?: any;
menuItems: ContextMenuItem[];
menuItemsSupplier?: () => ContextMenuItem[];
scrollContextElement: HTMLElement | null;
position: {
x: number;
@ -23,7 +23,6 @@ export class GraphContextMenuCtrl {
constructor($scope: any) {
this.isVisible = false;
this.menuItems = [];
this.scope = $scope;
}
@ -70,11 +69,7 @@ export class GraphContextMenuCtrl {
return this.source;
};
setMenuItems = (items: ContextMenuItem[]) => {
this.menuItems = items;
};
getMenuItems = () => {
return this.menuItems;
setMenuItemsSupplier = (menuItemsSupplier: () => ContextMenuItem[]) => {
this.menuItemsSupplier = menuItemsSupplier;
};
}

View File

@ -16,7 +16,6 @@ import GraphTooltip from './graph_tooltip';
import { ThresholdManager } from './threshold_manager';
import { TimeRegionManager } from './time_region_manager';
import { EventManager } from 'app/features/annotations/all';
import { LinkService, LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { convertToHistogramData } from './histogram';
import { alignYLevel } from './align_yaxes';
import config from 'app/core/config';
@ -25,12 +24,13 @@ import ReactDOM from 'react-dom';
import { GraphLegendProps, Legend } from './Legend/Legend';
import { GraphCtrl } from './module';
import { getValueFormat, ContextMenuItem, ContextMenuGroup, DataLinkBuiltInVars } from '@grafana/ui';
import { provideTheme } from 'app/core/utils/ConfigProvider';
import { DataLink, toUtc } from '@grafana/data';
import { GraphContextMenuCtrl, FlotDataPoint } from './GraphContextMenuCtrl';
import { getValueFormat, ContextMenuGroup, FieldDisplay, ContextMenuItem, getDisplayProcessor } from '@grafana/ui';
import { provideTheme, getCurrentTheme } from 'app/core/utils/ConfigProvider';
import { toUtc, LinkModelSupplier, DataFrameView } from '@grafana/data';
import { GraphContextMenuCtrl } from './GraphContextMenuCtrl';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ContextSrv } from 'app/core/services/context_srv';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
const LegendWithThemeProvider = provideTheme(Legend);
@ -50,7 +50,7 @@ class GraphElement {
timeRegionManager: TimeRegionManager;
legendElem: HTMLElement;
constructor(private scope: any, private elem: JQuery, private timeSrv: TimeSrv, private linkSrv: LinkService) {
constructor(private scope: any, private elem: JQuery, private timeSrv: TimeSrv) {
this.ctrl = scope.ctrl;
this.contextMenu = scope.ctrl.contextMenuCtrl;
this.dashboard = this.ctrl.dashboard;
@ -175,53 +175,48 @@ class GraphElement {
}
}
getContextMenuItems = (flotPosition: { x: number; y: number }, item?: FlotDataPoint): ContextMenuGroup[] => {
const dataLinks: DataLink[] = this.panel.options.dataLinks || [];
getContextMenuItemsSupplier = (
flotPosition: { x: number; y: number },
linksSupplier?: LinkModelSupplier<FieldDisplay>
): (() => ContextMenuGroup[]) => {
return () => {
// Fixed context menu items
const items: ContextMenuGroup[] = [
{
items: [
{
label: 'Add annotation',
icon: 'gicon gicon-annotation',
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
},
],
},
];
const items: ContextMenuGroup[] = [
{
items: [
{
label: 'Add annotation',
icon: 'gicon gicon-annotation',
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
},
],
},
];
if (!linksSupplier) {
return items;
}
return item
? [
...items,
{
items: [
...dataLinks.map<ContextMenuItem>(link => {
const linkUiModel = this.linkSrv.getDataLinkUIModel(
link,
{
...this.panel.scopedVars,
[DataLinkBuiltInVars.seriesName]: { value: item.series.alias, text: item.series.alias },
[DataLinkBuiltInVars.valueTime]: { value: item.datapoint[0], text: item.datapoint[0] },
},
item
);
return {
label: linkUiModel.title,
url: linkUiModel.href,
target: linkUiModel.target,
icon: `fa ${linkUiModel.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
};
}),
],
},
]
: items;
const dataLinks = [
{
items: linksSupplier.getLinks(this.panel.scopedVars).map<ContextMenuItem>(link => {
return {
label: link.title,
url: link.href,
target: link.target,
icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
};
}),
},
];
return [...items, ...dataLinks];
};
};
onPlotClick(event: JQueryEventObject, pos: any, item: any) {
const scrollContextElement = this.elem.closest('.view') ? this.elem.closest('.view').get()[0] : null;
const contextMenuSourceItem = item;
let contextMenuItems: ContextMenuItem[];
if (this.panel.xaxis.mode !== 'time') {
// Skip if panel in histogram or series mode
@ -239,12 +234,40 @@ class GraphElement {
return;
} else {
this.tooltip.clear(this.plot);
contextMenuItems = this.getContextMenuItems(pos, item) as ContextMenuItem[];
let linksSupplier: LinkModelSupplier<FieldDisplay>;
if (item) {
// pickup y-axis index to know which field's config to apply
const yAxisConfig = this.panel.yaxes[item.series.yaxis.n === 2 ? 1 : 0];
const fieldConfig = {
decimals: yAxisConfig.decimals,
links: this.panel.options.dataLinks || [],
};
const dataFrame = this.ctrl.dataList[item.series.dataFrameIndex];
const field = dataFrame.fields[item.series.fieldIndex];
const fieldDisplay = getDisplayProcessor({
config: fieldConfig,
theme: getCurrentTheme(),
})(field.values.get(item.dataIndex));
linksSupplier = this.panel.options.dataLinks
? getFieldLinksSupplier({
display: fieldDisplay,
name: field.name,
view: new DataFrameView(dataFrame),
rowIndex: item.dataIndex,
colIndex: item.series.fieldIndex,
field: fieldConfig,
})
: undefined;
}
this.scope.$apply(() => {
// Setting nearest CustomScrollbar element as a scroll context for graph context menu
this.contextMenu.setScrollContextElement(scrollContextElement);
this.contextMenu.setSource(contextMenuSourceItem);
this.contextMenu.setMenuItems(contextMenuItems);
this.contextMenu.setMenuItemsSupplier(this.getContextMenuItemsSupplier(pos, linksSupplier) as any);
this.contextMenu.toggleMenu(pos);
});
}
@ -363,7 +386,6 @@ class GraphElement {
this.thresholdManager.addFlotOptions(options, this.panel);
this.timeRegionManager.addFlotOptions(options, this.panel);
this.eventManager.addFlotEvents(this.annotations, options);
this.sortedSeries = this.sortSeries(this.data, this.panel);
this.callPlot(options, true);
}
@ -855,12 +877,12 @@ class GraphElement {
}
/** @ngInject */
function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv, linkSrv: LinkSrv) {
function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv) {
return {
restrict: 'A',
template: '',
link: (scope: any, elem: JQuery) => {
return new GraphElement(scope, elem, timeSrv, linkSrv);
return new GraphElement(scope, elem, timeSrv);
},
};
}

View File

@ -36,7 +36,7 @@ class GraphCtrl extends MetricsPanelCtrl {
subTabIndex: number;
processor: DataProcessor;
contextMenuCtrl: GraphContextMenuCtrl;
linkVariableSuggestions: VariableSuggestion[] = getDataLinksVariableSuggestions();
linkVariableSuggestions: VariableSuggestion[] = [];
panelDefaults: any = {
// datasource name, null = default datasource
@ -216,6 +216,8 @@ class GraphCtrl extends MetricsPanelCtrl {
range: this.range,
});
this.linkVariableSuggestions = getDataLinksVariableSuggestions(data);
this.dataWarning = null;
const datapointsCount = this.seriesList.reduce((prev, series) => {
return prev + series.datapoints.length;
@ -337,6 +339,10 @@ class GraphCtrl extends MetricsPanelCtrl {
formatDate = (date: DateTimeInput, format?: string) => {
return this.dashboard.formatDate.apply(this.dashboard, [date, format]);
};
getDataFrameByRefId = (refId: string) => {
return this.dataList.filter(dataFrame => dataFrame.refId === refId)[0];
};
}
export { GraphCtrl, GraphCtrl as PanelCtrl };

View File

@ -121,7 +121,7 @@ describe('grafanaGraph', () => {
$.plot = ctrl.plot = jest.fn();
scope.ctrl = ctrl;
link = graphDirective({} as any, {}, {} as any, {} as any).link(scope, {
link = graphDirective({} as any, {}, {} as any).link(scope, {
width: () => 500,
mouseleave: () => {},
bind: () => {},

View File

@ -8,7 +8,7 @@ const template = `
</div>
<div ng-if="ctrl.contextMenuCtrl.isVisible">
<graph-context-menu
items="ctrl.contextMenuCtrl.menuItems"
items="ctrl.contextMenuCtrl.menuItemsSupplier()"
onClose="ctrl.onContextMenuClose"
getContextMenuSource="ctrl.contextMenuCtrl.getSource"
formatSourceDate="ctrl.formatDate"

View File

@ -21,7 +21,7 @@ export const getGraphSeriesModel = (
const graphs: GraphSeriesXY[] = [];
const displayProcessor = getDisplayProcessor({
field: {
config: {
decimals: legendOptions.decimals,
},
});

View File

@ -36,7 +36,7 @@ export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChart
};
render() {
const { onOptionsChange, options } = this.props;
const { onOptionsChange, options, data } = this.props;
const { fieldOptions } = options;
const { defaults } = fieldOptions;
@ -51,7 +51,7 @@ export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChart
<FieldPropertiesEditor showMinMax={true} onChange={this.onDefaultsChange} value={defaults} />
</PanelOptionsGroup>
<PieChartOptionsBox onOptionsChange={onOptionsChange} options={options} />
<PieChartOptionsBox data={data} onOptionsChange={onOptionsChange} options={options} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />

View File

@ -238,7 +238,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
const processor = getDisplayProcessor({
field: {
config: {
...fieldInfo.field.config,
unit: panel.format,
decimals: panel.decimals,

View File

@ -70,8 +70,8 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
const { fieldOptions } = options;
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
? getDataLinksVariableSuggestions(this.props.data.series)
: getCalculationValueDataLinksVariableSuggestions(this.props.data.series);
return (
<>