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+. > 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" >}} {{< 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" >}} {{< 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 ``__url_time_range`` - current dashboard's time range (i.e. ``?from=now-6h&to=now``)
2. ``__url_time_range`` - will add current dashboard's time range to the URL (i.e. ``?from=now-6h&to=now``) ``__from`` - current dashboard's time range from value
3. ``__series_name`` - will add series name as a query param in the URL (i.e. ``?series=B-series``) ``__to`` - current dashboard's time range to value
4. ``__value_time`` - will add datapoint's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``)
#### 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) 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, 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 // Make sure it has a type
if (field.type === FieldType.other) { if (field.type === FieldType.other) {
const t = guessFieldTypeForField(field); const t = guessFieldTypeForField(field);
@ -33,13 +34,13 @@ export class FieldCache {
} }
this.fieldByType[field.type].push({ this.fieldByType[field.type].push({
...field, ...field,
index, index: i,
}); });
if (this.fieldByName[field.name]) { if (this.fieldByName[field.name]) {
console.warn('Duplicate field names in DataFrame: ', field.name); console.warn('Duplicate field names in DataFrame: ', field.name);
} else { } 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 { DataLink } from '@grafana/data';
import { FormField, Switch } from '../index'; import { FormField, Switch } from '../index';
import { VariableSuggestion } from './DataLinkSuggestions'; import { VariableSuggestion } from './DataLinkSuggestions';
import { css, cx } from 'emotion'; import { css } from 'emotion';
import { ThemeContext } from '../../themes/index'; import { ThemeContext } from '../../themes/index';
import { DataLinkInput } from './DataLinkInput'; import { DataLinkInput } from './DataLinkInput';
interface DataLinkEditorProps { interface DataLinkEditorProps {
index: number; index: number;
isLast: boolean;
value: DataLink; value: DataLink;
suggestions: VariableSuggestion[]; suggestions: VariableSuggestion[];
onChange: (index: number, link: DataLink) => void; onChange: (index: number, link: DataLink) => void;
@ -15,7 +16,7 @@ interface DataLinkEditorProps {
} }
export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo( export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
({ index, value, onChange, onRemove, suggestions }) => { ({ index, value, onChange, onRemove, suggestions, isLast }) => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const [title, setTitle] = useState(value.title); const [title, setTitle] = useState(value.title);
@ -38,46 +39,48 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
onChange(index, { ...value, targetBlank: !value.targetBlank }); onChange(index, { ...value, targetBlank: !value.targetBlank });
}; };
return ( const listItemStyle = css`
<div margin-bottom: ${theme.spacing.sm};
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 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 <FormField
label="URL" label="URL"
labelWidth={4} labelWidth={5}
inputEl={<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />} inputEl={<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />}
className={css` className={css`
width: 100%; width: 100%;
`} `}
/> />
{isLast && (
<Switch label="Open in new tab" checked={value.targetBlank || false} onChange={onOpenInNewTabChanged} /> <div className={infoTextStyle}>
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
<div className="gf-form"> CTRL+Space, or $ to open variable suggestions.
<button className="gf-form-label gf-form-label--btn" onClick={onRemoveClick}> </div>
<i className="fa fa-times" /> )}
</button>
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import React, { InputHTMLAttributes, FunctionComponent } from 'react';
import { FormLabel } from '../FormLabel/FormLabel'; import { FormLabel } from '../FormLabel/FormLabel';
import { PopoverContent } from '../Tooltip/Tooltip'; import { PopoverContent } from '../Tooltip/Tooltip';
import { cx } from 'emotion'; import { cx } from 'emotion';
export interface Props extends InputHTMLAttributes<HTMLInputElement> { export interface Props extends InputHTMLAttributes<HTMLInputElement> {
label: string; label: string;
tooltip?: PopoverContent; tooltip?: PopoverContent;
@ -33,7 +34,9 @@ export const FormField: FunctionComponent<Props> = ({
<FormLabel width={labelWidth} tooltip={tooltip}> <FormLabel width={labelWidth} tooltip={tooltip}>
{label} {label}
</FormLabel> </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> </div>
); );
}; };

View File

@ -36,10 +36,10 @@ export class AbstractList<T> extends React.PureComponent<AbstractListProps<T>> {
} }
render() { render() {
const { items, renderItem, getItemKey } = this.props; const { items, renderItem, getItemKey, className } = this.props;
const styles = this.getListStyles(); const styles = this.getListStyles();
return ( return (
<ul className={styles.list}> <ul className={cx(styles.list, className)}>
{items.map((item, i) => { {items.map((item, i) => {
return ( return (
<li className={styles.item} key={getItemKey ? getItemKey(item) : i}> <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> <div>
Template Variables: Template Variables:
<br /> <br />
{'$' + VAR_SERIES_NAME} {'${' + VAR_SERIES_NAME + '}'}
<br /> <br />
{'$' + VAR_FIELD_NAME} {'${' + VAR_FIELD_NAME + '}'}
<br /> <br />
{'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC} {'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
</div> </div>

View File

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

View File

@ -3,9 +3,17 @@ import { LinkModelSupplier } from '@grafana/data';
export const DataLinkBuiltInVars = { export const DataLinkBuiltInVars = {
keepTime: '__url_time_range', keepTime: '__url_time_range',
timeRangeFrom: '__from',
timeRangeTo: '__to',
includeVars: '__all_variables', includeVars: '__all_variables',
seriesName: '__series_name', seriesName: '__series.name',
valueTime: '__value_time', 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(), getDisplayProcessor(),
// Add a simple option that is not used (uses a different base class) // 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) // Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ field: { unit: 'locale' } }), getDisplayProcessor({ config: { unit: 'locale' } }),
]; ];
it('support null', () => { it('support null', () => {
@ -102,7 +102,7 @@ describe('Format value', () => {
it('should return if value isNaN', () => { it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = []; const valueMappings: ValueMapping[] = [];
const value = 'N/A'; const value = 'N/A';
const instance = getDisplayProcessor({ field: { mappings: valueMappings } }); const instance = getDisplayProcessor({ config: { mappings: valueMappings } });
const result = instance(value); const result = instance(value);
@ -113,7 +113,7 @@ describe('Format value', () => {
const valueMappings: ValueMapping[] = []; const valueMappings: ValueMapping[] = [];
const value = '6'; const value = '6';
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } }); const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } });
const result = instance(value); const result = instance(value);
@ -126,7 +126,7 @@ describe('Format value', () => {
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
]; ];
const value = '10'; const value = '10';
const instance = getDisplayProcessor({ field: { decimals: 1, mappings: valueMappings } }); const instance = getDisplayProcessor({ config: { decimals: 1, mappings: valueMappings } });
const result = instance(value); const result = instance(value);
@ -135,20 +135,20 @@ describe('Format value', () => {
it('should set auto decimals, 1 significant', () => { it('should set auto decimals, 1 significant', () => {
const value = 3.23; const value = 3.23;
const instance = getDisplayProcessor({ field: { decimals: null } }); const instance = getDisplayProcessor({ config: { decimals: null } });
expect(instance(value).text).toEqual('3.2'); expect(instance(value).text).toEqual('3.2');
}); });
it('should set auto decimals, 2 significant', () => { it('should set auto decimals, 2 significant', () => {
const value = 0.0245; const value = 0.0245;
const instance = getDisplayProcessor({ field: { decimals: null } }); const instance = getDisplayProcessor({ config: { decimals: null } });
expect(instance(value).text).toEqual('0.025'); expect(instance(value).text).toEqual('0.025');
}); });
it('should use override decimals', () => { it('should use override decimals', () => {
const value = 100030303; 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'); 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' }, { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
]; ];
const 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'); expect(instance(value).text).toEqual('1-20');
}); });
@ -169,25 +169,25 @@ describe('Format value', () => {
it('with value 1000 and unit short', () => { it('with value 1000 and unit short', () => {
const value = 1000; 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'); expect(instance(value).text).toEqual('1.000 K');
}); });
it('with value 1200 and unit short', () => { it('with value 1200 and unit short', () => {
const value = 1200; 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'); expect(instance(value).text).toEqual('1.200 K');
}); });
it('with value 1250 and unit short', () => { it('with value 1250 and unit short', () => {
const value = 1250; 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'); expect(instance(value).text).toEqual('1.250 K');
}); });
it('with value 10000000 and unit short', () => { it('with value 10000000 and unit short', () => {
const value = 1000000; 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'); expect(instance(value).text).toEqual('1.000 Mil');
}); });
}); });

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ export class KeybindingSrv {
this.bind('g p', this.goToProfile); this.bind('g p', this.goToProfile);
this.bind('s o', this.openSearch); this.bind('s o', this.openSearch);
this.bind('f', 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, "refresh": undefined,
"revision": undefined, "revision": undefined,
"schemaVersion": 19, "schemaVersion": 20,
"snapshot": undefined, "snapshot": undefined,
"style": "dark", "style": "dark",
"tags": Array [], "tags": Array [],
@ -191,7 +191,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
], ],
"refresh": undefined, "refresh": undefined,
"revision": undefined, "revision": undefined,
"schemaVersion": 19, "schemaVersion": 20,
"snapshot": undefined, "snapshot": undefined,
"style": "dark", "style": "dark",
"tags": Array [], "tags": Array [],
@ -315,7 +315,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
], ],
"refresh": undefined, "refresh": undefined,
"revision": undefined, "revision": undefined,
"schemaVersion": 19, "schemaVersion": 20,
"snapshot": undefined, "snapshot": undefined,
"style": "dark", "style": "dark",
"tags": Array [], "tags": Array [],
@ -426,7 +426,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
], ],
"refresh": undefined, "refresh": undefined,
"revision": undefined, "revision": undefined,
"schemaVersion": 19, "schemaVersion": 20,
"snapshot": undefined, "snapshot": undefined,
"style": "dark", "style": "dark",
"tags": Array [], "tags": Array [],
@ -521,7 +521,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
], ],
"refresh": undefined, "refresh": undefined,
"revision": undefined, "revision": undefined,
"schemaVersion": 19, "schemaVersion": 20,
"snapshot": undefined, "snapshot": undefined,
"style": "dark", "style": "dark",
"tags": Array [], "tags": Array [],

View File

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

View File

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

View File

@ -128,7 +128,7 @@ describe('DashboardModel', () => {
}); });
it('dashboard schema version should be set to latest', () => { 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', () => { 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`); 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[]) { function createRow(options: any, panelDescriptions: any[]) {

View File

@ -33,7 +33,7 @@ export class DashboardMigrator {
let i, j, k, n; let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion; const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = []; const panelUpgrades = [];
this.dashboard.schemaVersion = 19; this.dashboard.schemaVersion = 20;
if (oldVersion === this.dashboard.schemaVersion) { if (oldVersion === this.dashboard.schemaVersion) {
return; 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) { if (panelUpgrades.length === 0) {
return; return;
} }
@ -666,3 +693,26 @@ function upgradePanelLink(link: any): DataLink {
targetBlank: link.targetBlank, 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 { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { FieldDisplay, DataLinkBuiltInVars } from '@grafana/ui'; import { FieldDisplay } from '@grafana/ui';
import { LinkModelSupplier, getTimeField, ScopedVars } from '@grafana/data'; import { LinkModelSupplier, getTimeField, Labels, ScopedVars, ScopedVar } from '@grafana/data';
import { getLinkSrv } from './link_srv'; 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 * Link suppliers creates link models based on a link origin
*/ */
@ -14,29 +38,53 @@ export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier<Fi
} }
return { return {
getLinks: (_scopedVars?: any) => { getLinks: (_scopedVars?: any) => {
const scopedVars: ScopedVars = {}; const scopedVars: DataLinkScopedVars = {};
// TODO, add values to scopedVars and/or pass objects to event listeners
if (value.view) { if (value.view) {
scopedVars[DataLinkBuiltInVars.seriesName] = { const { dataFrame } = value.view;
scopedVars['__series'] = {
value: {
name: dataFrame.name,
labels: dataFrame.labels,
refId: dataFrame.refId,
},
text: 'Series', 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) { if (field) {
console.log('Full Field Info:', 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); const { timeField } = getTimeField(dataFrame);
if (timeField) { scopedVars['__value'] = {
scopedVars[DataLinkBuiltInVars.valueTime] = { value: {
text: 'Value time', raw: field.values.get(value.rowIndex),
value: timeField.values.get(value.row), 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 { } else {
console.log('VALUE', value); 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 coreModule from 'app/core/core_module';
import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui'; 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[] => [ export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
...templateSrv.variables.map(variable => ({ ...templateSrv.variables.map(variable => ({
value: variable.name as string, value: variable.name as string,
label: variable.name,
origin: VariableOrigin.Template, origin: VariableOrigin.Template,
})), })),
{ {
value: `${DataLinkBuiltInVars.includeVars}`, value: `${DataLinkBuiltInVars.includeVars}`,
label: 'All variables',
documentation: 'Adds current variables', documentation: 'Adds current variables',
origin: VariableOrigin.BuiltIn, origin: VariableOrigin.Template,
},
{
value: `${DataLinkBuiltInVars.keepTime}`,
documentation: 'Adds current time range',
origin: VariableOrigin.BuiltIn,
}, },
...timeRangeVars,
]; ];
export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [ const getSeriesVars = (dataFrames: DataFrame[]) => {
...getPanelLinksVariableSuggestions(), const labels = _.flatten(dataFrames.map(df => Object.keys(df.labels || {})));
{
value: `${DataLinkBuiltInVars.seriesName}`, return [
documentation: 'Adds series name', {
origin: VariableOrigin.BuiltIn, 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}`, value: `${DataLinkBuiltInVars.valueTime}`,
label: 'Time',
documentation: 'Time value of the clicked datapoint (in ms epoch)', documentation: 'Time value of the clicked datapoint (in ms epoch)',
origin: VariableOrigin.BuiltIn, origin: VariableOrigin.Value,
}, };
];
export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [ return [...seriesVars, ...fieldVars, ...valueVars, valueTimeVar, ...getPanelLinksVariableSuggestions()];
...getPanelLinksVariableSuggestions(), };
{
value: `${DataLinkBuiltInVars.seriesName}`, export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
documentation: 'Adds series name', const seriesVars = getSeriesVars(dataFrames);
origin: VariableOrigin.BuiltIn, 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 { export interface LinkService {
getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>; 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 timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
const info: LinkModel<T> = { const info: LinkModel<T> = {
href: link.url, href: link.url.replace(/\s|\n/g, ''),
title: this.templateSrv.replace(link.title || '', scopedVars), title: this.templateSrv.replace(link.title || '', scopedVars),
target: link.targetBlank ? '_blank' : '_self', target: link.targetBlank ? '_blank' : '_self',
origin, origin,
}; };
this.templateSrv.fillVariableValuesForUrl(params, scopedVars); this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
const variablesQuery = toUrlParams(params); const variablesQuery = toUrlParams(params);
info.href = this.templateSrv.replace(link.url, { info.href = this.templateSrv.replace(info.href, {
...scopedVars, ...scopedVars,
[DataLinkBuiltInVars.keepTime]: { [DataLinkBuiltInVars.keepTime]: {
text: timeRangeUrl, text: timeRangeUrl,

View File

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

View File

@ -24,6 +24,27 @@ describe('templateSrv', () => {
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); 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', () => { it('should replace $test with scoped value', () => {
const target = _templateSrv.replace('this.$test.filters', { const target = _templateSrv.replace('this.$test.filters', {
test: { value: 'mupp', text: 'asd' }, test: { value: 'mupp', text: 'asd' },

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import GraphTooltip from './graph_tooltip';
import { ThresholdManager } from './threshold_manager'; import { ThresholdManager } from './threshold_manager';
import { TimeRegionManager } from './time_region_manager'; import { TimeRegionManager } from './time_region_manager';
import { EventManager } from 'app/features/annotations/all'; import { EventManager } from 'app/features/annotations/all';
import { LinkService, LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { convertToHistogramData } from './histogram'; import { convertToHistogramData } from './histogram';
import { alignYLevel } from './align_yaxes'; import { alignYLevel } from './align_yaxes';
import config from 'app/core/config'; import config from 'app/core/config';
@ -25,12 +24,13 @@ import ReactDOM from 'react-dom';
import { GraphLegendProps, Legend } from './Legend/Legend'; import { GraphLegendProps, Legend } from './Legend/Legend';
import { GraphCtrl } from './module'; import { GraphCtrl } from './module';
import { getValueFormat, ContextMenuItem, ContextMenuGroup, DataLinkBuiltInVars } from '@grafana/ui'; import { getValueFormat, ContextMenuGroup, FieldDisplay, ContextMenuItem, getDisplayProcessor } from '@grafana/ui';
import { provideTheme } from 'app/core/utils/ConfigProvider'; import { provideTheme, getCurrentTheme } from 'app/core/utils/ConfigProvider';
import { DataLink, toUtc } from '@grafana/data'; import { toUtc, LinkModelSupplier, DataFrameView } from '@grafana/data';
import { GraphContextMenuCtrl, FlotDataPoint } from './GraphContextMenuCtrl'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ContextSrv } from 'app/core/services/context_srv'; import { ContextSrv } from 'app/core/services/context_srv';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
const LegendWithThemeProvider = provideTheme(Legend); const LegendWithThemeProvider = provideTheme(Legend);
@ -50,7 +50,7 @@ class GraphElement {
timeRegionManager: TimeRegionManager; timeRegionManager: TimeRegionManager;
legendElem: HTMLElement; 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.ctrl = scope.ctrl;
this.contextMenu = scope.ctrl.contextMenuCtrl; this.contextMenu = scope.ctrl.contextMenuCtrl;
this.dashboard = this.ctrl.dashboard; this.dashboard = this.ctrl.dashboard;
@ -175,53 +175,48 @@ class GraphElement {
} }
} }
getContextMenuItems = (flotPosition: { x: number; y: number }, item?: FlotDataPoint): ContextMenuGroup[] => { getContextMenuItemsSupplier = (
const dataLinks: DataLink[] = this.panel.options.dataLinks || []; 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[] = [ if (!linksSupplier) {
{ return items;
items: [ }
{
label: 'Add annotation',
icon: 'gicon gicon-annotation',
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
},
],
},
];
return item const dataLinks = [
? [ {
...items, items: linksSupplier.getLinks(this.panel.scopedVars).map<ContextMenuItem>(link => {
{ return {
items: [ label: link.title,
...dataLinks.map<ContextMenuItem>(link => { url: link.href,
const linkUiModel = this.linkSrv.getDataLinkUIModel( target: link.target,
link, icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-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 [...items, ...dataLinks];
); };
return {
label: linkUiModel.title,
url: linkUiModel.href,
target: linkUiModel.target,
icon: `fa ${linkUiModel.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
};
}),
],
},
]
: items;
}; };
onPlotClick(event: JQueryEventObject, pos: any, item: any) { onPlotClick(event: JQueryEventObject, pos: any, item: any) {
const scrollContextElement = this.elem.closest('.view') ? this.elem.closest('.view').get()[0] : null; const scrollContextElement = this.elem.closest('.view') ? this.elem.closest('.view').get()[0] : null;
const contextMenuSourceItem = item; const contextMenuSourceItem = item;
let contextMenuItems: ContextMenuItem[];
if (this.panel.xaxis.mode !== 'time') { if (this.panel.xaxis.mode !== 'time') {
// Skip if panel in histogram or series mode // Skip if panel in histogram or series mode
@ -239,12 +234,40 @@ class GraphElement {
return; return;
} else { } else {
this.tooltip.clear(this.plot); 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(() => { this.scope.$apply(() => {
// Setting nearest CustomScrollbar element as a scroll context for graph context menu // Setting nearest CustomScrollbar element as a scroll context for graph context menu
this.contextMenu.setScrollContextElement(scrollContextElement); this.contextMenu.setScrollContextElement(scrollContextElement);
this.contextMenu.setSource(contextMenuSourceItem); this.contextMenu.setSource(contextMenuSourceItem);
this.contextMenu.setMenuItems(contextMenuItems); this.contextMenu.setMenuItemsSupplier(this.getContextMenuItemsSupplier(pos, linksSupplier) as any);
this.contextMenu.toggleMenu(pos); this.contextMenu.toggleMenu(pos);
}); });
} }
@ -363,7 +386,6 @@ class GraphElement {
this.thresholdManager.addFlotOptions(options, this.panel); this.thresholdManager.addFlotOptions(options, this.panel);
this.timeRegionManager.addFlotOptions(options, this.panel); this.timeRegionManager.addFlotOptions(options, this.panel);
this.eventManager.addFlotEvents(this.annotations, options); this.eventManager.addFlotEvents(this.annotations, options);
this.sortedSeries = this.sortSeries(this.data, this.panel); this.sortedSeries = this.sortSeries(this.data, this.panel);
this.callPlot(options, true); this.callPlot(options, true);
} }
@ -855,12 +877,12 @@ class GraphElement {
} }
/** @ngInject */ /** @ngInject */
function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv, linkSrv: LinkSrv) { function graphDirective(timeSrv: TimeSrv, popoverSrv: any, contextSrv: ContextSrv) {
return { return {
restrict: 'A', restrict: 'A',
template: '', template: '',
link: (scope: any, elem: JQuery) => { 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; subTabIndex: number;
processor: DataProcessor; processor: DataProcessor;
contextMenuCtrl: GraphContextMenuCtrl; contextMenuCtrl: GraphContextMenuCtrl;
linkVariableSuggestions: VariableSuggestion[] = getDataLinksVariableSuggestions(); linkVariableSuggestions: VariableSuggestion[] = [];
panelDefaults: any = { panelDefaults: any = {
// datasource name, null = default datasource // datasource name, null = default datasource
@ -216,6 +216,8 @@ class GraphCtrl extends MetricsPanelCtrl {
range: this.range, range: this.range,
}); });
this.linkVariableSuggestions = getDataLinksVariableSuggestions(data);
this.dataWarning = null; this.dataWarning = null;
const datapointsCount = this.seriesList.reduce((prev, series) => { const datapointsCount = this.seriesList.reduce((prev, series) => {
return prev + series.datapoints.length; return prev + series.datapoints.length;
@ -337,6 +339,10 @@ class GraphCtrl extends MetricsPanelCtrl {
formatDate = (date: DateTimeInput, format?: string) => { formatDate = (date: DateTimeInput, format?: string) => {
return this.dashboard.formatDate.apply(this.dashboard, [date, format]); 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 }; export { GraphCtrl, GraphCtrl as PanelCtrl };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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