Procházet zdrojové kódy

DataLinks: enable data links in Gauge, BarGauge and SingleStat2 panel (#18605)

* datalink on field

* add dataFrame to view

* Use scoped variables to pass series name and value time to data links interpolation

* Use scoped variables to pass series name and value time to data links interpolation

* Enable value specific variable suggestions when Gauge is displaying values

* Fix prettier

* Add basic context menu with data links to GaugePanel

* Fix incorrect import in grafana/ui

* Add custom cursor indicating datalinks available via context menu (in Gauge only now)

* Add data links to SingleStat2

* Minor refactor

* Retrieve data links in a lazy way

* Update test to respect links retrieval being lazy

* delay link creation

* cleanup

* Add origin to LinkModel and introduce field & panel links suppliers

* Add value time and series name field link supplier

* Remove links prop from visualization and implement common UI for data links context menu

* Update snapshot

* Rename className prop to clickTargetClassName

* Simplify condition

* Updated drilldown dashboard and minor changes

* Use class name an onClick handler on the top level dom element in visualization

* Enable series name interpolation when presented value is a calculation
Ryan McKinley před 6 roky
rodič
revize
ff6b8c5adc
38 změnil soubory, kde provedl 707 přidání a 242 odebrání
  1. 120 8
      devenv/dev-dashboards/feature-templating/testdata-nested-variables.json
  2. 4 0
      packages/grafana-data/src/types/dataFrame.ts
  3. 25 0
      packages/grafana-data/src/types/dataLink.ts
  4. 4 0
      packages/grafana-data/src/utils/dataFrameView.ts
  5. 10 4
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  6. 33 23
      packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap
  7. 13 6
      packages/grafana-ui/src/components/BigValue/BigValue.tsx
  8. 2 1
      packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx
  9. 35 0
      packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx
  10. 1 0
      packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx
  11. 33 0
      packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx
  12. 1 1
      packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx
  13. 1 1
      packages/grafana-ui/src/components/FormField/_FormField.scss
  14. 25 12
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  15. 1 1
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
  16. 17 11
      packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
  17. 37 29
      packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
  18. 1 0
      packages/grafana-ui/src/components/index.ts
  19. 24 0
      packages/grafana-ui/src/utils/dataLinks.ts
  20. 41 24
      packages/grafana-ui/src/utils/fieldDisplay.ts
  21. 1 0
      packages/grafana-ui/src/utils/index.ts
  22. 2 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  23. 1 8
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx
  24. 7 10
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
  25. 1 1
      public/app/features/dashboard/state/DashboardMigrator.test.ts
  26. 1 1
      public/app/features/dashboard/state/DashboardMigrator.ts
  27. 8 8
      public/app/features/panel/panel_ctrl.ts
  28. 66 0
      public/app/features/panel/panellinks/linkSuppliers.ts
  29. 15 47
      public/app/features/panel/panellinks/link_srv.ts
  30. 20 7
      public/app/features/panel/panellinks/specs/link_srv.test.ts
  31. 22 13
      public/app/plugins/panel/bargauge/BarGaugePanel.tsx
  32. 24 1
      public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx
  33. 21 12
      public/app/plugins/panel/gauge/GaugePanel.tsx
  34. 25 1
      public/app/plugins/panel/gauge/GaugePanelEditor.tsx
  35. 10 5
      public/app/plugins/panel/graph/graph.ts
  36. 4 3
      public/app/plugins/panel/singlestat/module.ts
  37. 25 1
      public/app/plugins/panel/singlestat2/SingleStatEditor.tsx
  38. 26 2
      public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

+ 120 - 8
devenv/dev-dashboards/feature-templating/testdata-nested-variables.json

@@ -15,14 +15,15 @@
   "editable": true,
   "gnetId": null,
   "graphTooltip": 0,
-  "iteration": 1565097360786,
+  "id": 13844,
+  "iteration": 1566896059256,
   "links": [],
   "panels": [
     {
       "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
       "gridPos": {
-        "h": 6,
-        "w": 14,
+        "h": 9,
+        "w": 12,
         "x": 0,
         "y": 0
       },
@@ -55,9 +56,9 @@
         "thresholdMarkers": true
       },
       "gridPos": {
-        "h": 6,
-        "w": 10,
-        "x": 14,
+        "h": 9,
+        "w": 4,
+        "x": 12,
         "y": 0
       },
       "id": 6,
@@ -116,6 +117,117 @@
       ],
       "valueName": "avg"
     },
+    {
+      "cacheTimeout": null,
+      "gridPos": {
+        "h": 9,
+        "w": 4,
+        "x": 16,
+        "y": 0
+      },
+      "id": 8,
+      "links": [],
+      "options": {
+        "fieldOptions": {
+          "calcs": ["mean"],
+          "defaults": {
+            "links": [
+              {
+                "targetBlank": true,
+                "title": "Go to drilldown",
+                "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}"
+              }
+            ],
+            "mappings": [],
+            "max": 100,
+            "min": 0,
+            "nullValueMode": "connected",
+            "thresholds": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ],
+            "unit": "none"
+          },
+          "override": {},
+          "values": false
+        },
+        "orientation": "horizontal",
+        "showThresholdLabels": false,
+        "showThresholdMarkers": true
+      },
+      "pluginVersion": "6.4.0-pre",
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "random_walk"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "React gauge datalink",
+      "type": "gauge"
+    },
+    {
+      "cacheTimeout": null,
+      "gridPos": {
+        "h": 9,
+        "w": 4,
+        "x": 20,
+        "y": 0
+      },
+      "id": 9,
+      "links": [],
+      "options": {
+        "displayMode": "basic",
+        "fieldOptions": {
+          "calcs": ["mean"],
+          "defaults": {
+            "links": [
+              {
+                "targetBlank": true,
+                "title": "Go to drilldown",
+                "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}"
+              }
+            ],
+            "mappings": [],
+            "max": 100,
+            "min": 0,
+            "nullValueMode": "connected",
+            "thresholds": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ],
+            "unit": "none"
+          },
+          "override": {},
+          "values": false
+        },
+        "orientation": "vertical"
+      },
+      "pluginVersion": "6.4.0-pre",
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "random_walk"
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "React gauge datalink",
+      "type": "bargauge"
+    },
     {
       "aliasColors": {},
       "bars": false,
@@ -128,7 +240,7 @@
         "h": 13,
         "w": 24,
         "x": 0,
-        "y": 6
+        "y": 9
       },
       "id": 2,
       "legend": {
@@ -296,5 +408,5 @@
   "timezone": "",
   "title": "Templating - Nested Template Variables",
   "uid": "-Y-tnEDWk",
-  "version": 11
+  "version": 2
 }

+ 4 - 0
packages/grafana-data/src/types/dataFrame.ts

@@ -3,6 +3,7 @@ import { ValueMapping } from './valueMapping';
 import { QueryResultBase, Labels, NullValueMode } from './data';
 import { FieldCalcs } from '../utils/index';
 import { DisplayProcessor } from './displayValue';
+import { DataLink } from './dataLink';
 
 export enum FieldType {
   time = 'time', // or date
@@ -36,6 +37,9 @@ export interface FieldConfig {
   // Used when reducing field values
   nullValueMode?: NullValueMode;
 
+  // The behavior when clicking on a result
+  links?: DataLink[];
+
   // Alternative to empty string
   noValue?: string;
 }

+ 25 - 0
packages/grafana-data/src/types/dataLink.ts

@@ -1,5 +1,30 @@
+/**
+ * Link configuration.  The values may contain variables that need to be
+ * processed before running
+ */
 export interface DataLink {
   url: string;
   title: string;
   targetBlank?: boolean;
 }
+
+export type LinkTarget = '_blank' | '_self';
+
+/**
+ * Processed Link Model.  The values are ready to use
+ */
+export interface LinkModel<T> {
+  href: string;
+  title: string;
+  target: LinkTarget;
+  origin: T;
+}
+
+/**
+ * Provides a way to produce links on demand
+ *
+ * TODO: ScopedVars in in GrafanaUI package!
+ */
+export interface LinkModelSupplier<T extends object> {
+  getLinks(scopedVars?: any): Array<LinkModel<T>>;
+}

+ 4 - 0
packages/grafana-data/src/utils/dataFrameView.ts

@@ -44,6 +44,10 @@ export class DataFrameView<T = any> implements Vector<T> {
     this.obj = obj;
   }
 
+  get dataFrame() {
+    return this.data;
+  }
+
   get length() {
     return this.data.length;
   }

+ 10 - 4
packages/grafana-ui/src/components/BarGauge/BarGauge.tsx

@@ -26,6 +26,8 @@ export interface Props extends Themeable {
   orientation: VizOrientation;
   itemSpacing?: number;
   displayMode: 'basic' | 'lcd' | 'gradient';
+  onClick?: React.MouseEventHandler<HTMLElement>;
+  className?: string;
 }
 
 export class BarGauge extends PureComponent<Props> {
@@ -43,16 +45,20 @@ export class BarGauge extends PureComponent<Props> {
   };
 
   render() {
+    const { onClick, className } = this.props;
     const { title } = this.props.value;
+    const styles = getTitleStyles(this.props);
 
     if (!title) {
-      return this.renderBarAndValue();
+      return (
+        <div style={styles.wrapper} onClick={onClick} className={className}>
+          {this.renderBarAndValue()}
+        </div>
+      );
     }
 
-    const styles = getTitleStyles(this.props);
-
     return (
-      <div style={styles.wrapper}>
+      <div style={styles.wrapper} onClick={onClick} className={className}>
         <div style={styles.title}>{title}</div>
         {this.renderBarAndValue()}
       </div>

+ 33 - 23
packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap

@@ -4,41 +4,51 @@ exports[`BarGauge Render with basic options should render 1`] = `
 <div
   style={
     Object {
-      "alignItems": "center",
       "display": "flex",
-      "flexDirection": "row-reverse",
-      "justifyContent": "flex-end",
+      "flexDirection": "column",
+      "overflow": "hidden",
     }
   }
 >
   <div
-    className="bar-gauge__value"
     style={
       Object {
         "alignItems": "center",
-        "color": "#73BF69",
         "display": "flex",
-        "fontSize": "27.2727px",
-        "height": "300px",
-        "lineHeight": 1,
-        "paddingLeft": "10px",
-        "width": "60px",
+        "flexDirection": "row-reverse",
+        "justifyContent": "flex-end",
       }
     }
   >
-    25
-  </div>
-  <div
-    style={
-      Object {
-        "background": "rgba(115, 191, 105, 0.25)",
-        "borderRadius": "3px",
-        "borderRight": "2px solid #73BF69",
-        "height": "300px",
-        "transition": "width 1s",
-        "width": "60px",
+    <div
+      className="bar-gauge__value"
+      style={
+        Object {
+          "alignItems": "center",
+          "color": "#73BF69",
+          "display": "flex",
+          "fontSize": "27.2727px",
+          "height": "300px",
+          "lineHeight": 1,
+          "paddingLeft": "10px",
+          "width": "60px",
+        }
       }
-    }
-  />
+    >
+      25
+    </div>
+    <div
+      style={
+        Object {
+          "background": "rgba(115, 191, 105, 0.25)",
+          "borderRadius": "3px",
+          "borderRight": "2px solid #73BF69",
+          "height": "300px",
+          "transition": "width 1s",
+          "width": "60px",
+        }
+      }
+    />
+  </div>
 </div>
 `;

+ 13 - 6
packages/grafana-ui/src/components/BigValue/BigValue.tsx

@@ -1,7 +1,7 @@
 // Library
 import React, { PureComponent, ReactNode, CSSProperties } from 'react';
 import $ from 'jquery';
-import { css } from 'emotion';
+import { css, cx } from 'emotion';
 import { DisplayValue } from '@grafana/data';
 
 // Utils
@@ -27,6 +27,8 @@ export interface Props extends Themeable {
   suffix?: DisplayValue;
   sparkline?: BigValueSparkline;
   backgroundColor?: string;
+  onClick?: React.MouseEventHandler<HTMLElement>;
+  className?: string;
 }
 
 /*
@@ -119,15 +121,19 @@ export class BigValue extends PureComponent<Props> {
   }
 
   render() {
-    const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
+    const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props;
 
     return (
       <div
-        className={css({
-          position: 'relative',
-          display: 'table',
-        })}
+        className={cx(
+          css({
+            position: 'relative',
+            display: 'table',
+          }),
+          className
+        )}
         style={{ width, height, backgroundColor }}
+        onClick={onClick}
       >
         {value.title && (
           <div
@@ -143,6 +149,7 @@ export class BigValue extends PureComponent<Props> {
             {value.title}
           </div>
         )}
+
         <span
           className={css({
             lineHeight: 1,

+ 2 - 1
packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx

@@ -3,10 +3,11 @@ import { css, cx } from 'emotion';
 import useClickAway from 'react-use/lib/useClickAway';
 import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
 import { Portal, List } from '../index';
+import { LinkTarget } from '@grafana/data';
 
 export interface ContextMenuItem {
   label: string;
-  target?: string;
+  target?: LinkTarget;
   icon?: string;
   url?: string;
   onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;

+ 35 - 0
packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx

@@ -0,0 +1,35 @@
+import React, { useState } from 'react';
+import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu';
+
+interface WithContextMenuProps {
+  children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
+  getContextMenuItems: () => ContextMenuGroup[];
+}
+
+export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
+  const [isMenuOpen, setIsMenuOpen] = useState(false);
+  const [menuPosition, setMenuPositon] = useState({ x: 0, y: 0 });
+
+  return (
+    <>
+      {children({
+        openMenu: e => {
+          setIsMenuOpen(true);
+          setMenuPositon({
+            x: e.pageX,
+            y: e.pageY,
+          });
+        },
+      })}
+
+      {isMenuOpen && (
+        <ContextMenu
+          onClose={() => setIsMenuOpen(false)}
+          x={menuPosition.x}
+          y={menuPosition.y}
+          items={getContextMenuItems()}
+        />
+      )}
+    </>
+  );
+};

+ 1 - 0
packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx

@@ -59,6 +59,7 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
           onBlur={onTitleBlur}
           inputWidth={15}
           labelWidth={5}
+          placeholder="Show details"
         />
 
         <FormField

+ 33 - 0
packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { WithContextMenu } from '../ContextMenu/WithContextMenu';
+import { LinkModelSupplier } from '@grafana/data';
+import { linkModelToContextMenuItems } from '../../utils/dataLinks';
+import { css } from 'emotion';
+
+interface DataLinksContextMenuProps {
+  children: (props: { openMenu?: React.MouseEventHandler<HTMLElement>; targetClassName?: string }) => JSX.Element;
+  links?: LinkModelSupplier<any>;
+}
+
+export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ children, links }) => {
+  if (!links) {
+    return children({});
+  }
+
+  const getDataLinksContextMenuItems = () => {
+    return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
+  };
+
+  // Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization
+  const targetClassName = css`
+    cursor: context-menu;
+  `;
+
+  return (
+    <WithContextMenu getContextMenuItems={getDataLinksContextMenuItems}>
+      {({ openMenu }) => {
+        return children({ openMenu, targetClassName });
+      }}
+    </WithContextMenu>
+  );
+};

+ 1 - 1
packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx

@@ -68,7 +68,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
 
       {(!value || (value && value.length < (maxLinks || Infinity))) && (
         <Button variant="inverse" icon="fa fa-plus" onClick={() => onAdd()}>
-          Create link
+          Add link
         </Button>
       )}
     </>

+ 1 - 1
packages/grafana-ui/src/components/FormField/_FormField.scss

@@ -2,7 +2,7 @@
   margin-bottom: $space-xxs;
   display: flex;
   flex-direction: row;
-  align-items: center;
+  align-items: flex-start;
   text-align: left;
   position: relative;
 

+ 25 - 12
packages/grafana-ui/src/components/Gauge/Gauge.tsx

@@ -15,6 +15,8 @@ export interface Props extends Themeable {
   showThresholdLabels: boolean;
   width: number;
   value: DisplayValue;
+  onClick?: React.MouseEventHandler<HTMLElement>;
+  className?: string;
 }
 
 const FONT_SCALE = 1;
@@ -133,24 +135,16 @@ export class Gauge extends PureComponent<Props> {
     }
   }
 
-  render() {
-    const { width, value, height } = this.props;
+  renderVisualization = () => {
+    const { width, value, height, onClick } = this.props;
     const autoProps = calculateGaugeAutoProps(width, height, value.title);
 
     return (
-      <div
-        style={{
-          width: '100%',
-          height: '100%',
-          display: 'flex',
-          flexDirection: 'column',
-          justifyContent: 'center',
-          overflow: 'hidden',
-        }}
-      >
+      <>
         <div
           style={{ height: `${autoProps.gaugeHeight}px`, width: '100%' }}
           ref={element => (this.canvasElement = element)}
+          onClick={onClick}
         />
         {autoProps.showLabel && (
           <div
@@ -163,11 +157,30 @@ export class Gauge extends PureComponent<Props> {
               position: 'relative',
               width: '100%',
               top: '-4px',
+              cursor: 'default',
             }}
           >
             {value.title}
           </div>
         )}
+      </>
+    );
+  };
+
+  render() {
+    return (
+      <div
+        style={{
+          width: '100%',
+          height: '100%',
+          display: 'flex',
+          flexDirection: 'column',
+          justifyContent: 'center',
+          overflow: 'hidden',
+        }}
+        className={this.props.className}
+      >
+        {this.renderVisualization()}
       </div>
     );
   }

+ 1 - 1
packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx

@@ -59,7 +59,7 @@ describe('Next id to add', () => {
   it('should be 4', () => {
     const { instance } = setup();
 
-    instance.addMapping();
+    instance.onAddMapping();
 
     expect(instance.state.nextIdToAdd).toEqual(4);
   });

+ 17 - 11
packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
 
 import MappingRow from './MappingRow';
 import { MappingType, ValueMapping } from '@grafana/data';
+import { Button } from '../index';
 import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
 
 export interface Props {
@@ -30,7 +31,7 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
     return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
   }
 
-  addMapping = () =>
+  onAddMapping = () =>
     this.setState(prevState => ({
       valueMappings: [
         ...prevState.valueMappings,
@@ -81,16 +82,21 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
     const { valueMappings } = this.state;
 
     return (
-      <PanelOptionsGroup title="Add value mapping" onAdd={this.addMapping}>
-        {valueMappings.length > 0 &&
-          valueMappings.map((valueMapping, index) => (
-            <MappingRow
-              key={`${valueMapping.text}-${index}`}
-              valueMapping={valueMapping}
-              updateValueMapping={this.updateGauge}
-              removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
-            />
-          ))}
+      <PanelOptionsGroup title="Value mappings">
+        <div>
+          {valueMappings.length > 0 &&
+            valueMappings.map((valueMapping, index) => (
+              <MappingRow
+                key={`${valueMapping.text}-${index}`}
+                valueMapping={valueMapping}
+                updateValueMapping={this.updateGauge}
+                removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
+              />
+            ))}
+          <Button variant="inverse" icon="fa fa-plus" onClick={this.onAddMapping}>
+            Add mapping
+          </Button>
+        </div>
       </PanelOptionsGroup>
     );
   }

+ 37 - 29
packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap

@@ -2,37 +2,45 @@
 
 exports[`Render should render component 1`] = `
 <Component
-  onAdd={[Function]}
-  title="Add value mapping"
+  title="Value mappings"
 >
-  <MappingRow
-    key="Ok-0"
-    removeValueMapping={[Function]}
-    updateValueMapping={[Function]}
-    valueMapping={
-      Object {
-        "id": 1,
-        "operator": "",
-        "text": "Ok",
-        "type": 1,
-        "value": "20",
+  <div>
+    <MappingRow
+      key="Ok-0"
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
+        Object {
+          "id": 1,
+          "operator": "",
+          "text": "Ok",
+          "type": 1,
+          "value": "20",
+        }
       }
-    }
-  />
-  <MappingRow
-    key="Meh-1"
-    removeValueMapping={[Function]}
-    updateValueMapping={[Function]}
-    valueMapping={
-      Object {
-        "from": "21",
-        "id": 2,
-        "operator": "",
-        "text": "Meh",
-        "to": "30",
-        "type": 2,
+    />
+    <MappingRow
+      key="Meh-1"
+      removeValueMapping={[Function]}
+      updateValueMapping={[Function]}
+      valueMapping={
+        Object {
+          "from": "21",
+          "id": 2,
+          "operator": "",
+          "text": "Meh",
+          "to": "30",
+          "type": 2,
+        }
       }
-    }
-  />
+    />
+    <Button
+      icon="fa fa-plus"
+      onClick={[Function]}
+      variant="inverse"
+    >
+      Add mapping
+    </Button>
+  </div>
 </Component>
 `;

+ 1 - 0
packages/grafana-ui/src/components/index.ts

@@ -76,4 +76,5 @@ export { CallToActionCard } from './CallToActionCard/CallToActionCard';
 export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu';
 export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions';
 export { DataLinksEditor } from './DataLinks/DataLinksEditor';
+export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
 export { SeriesIcon } from './Legend/SeriesIcon';

+ 24 - 0
packages/grafana-ui/src/utils/dataLinks.ts

@@ -0,0 +1,24 @@
+import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
+import { LinkModelSupplier } from '@grafana/data';
+
+export const DataLinkBuiltInVars = {
+  keepTime: '__url_time_range',
+  includeVars: '__all_variables',
+  seriesName: '__series_name',
+  valueTime: '__value_time',
+};
+
+/**
+ * Delays creating links until we need to open the ContextMenu
+ */
+export const linkModelToContextMenuItems: (links: LinkModelSupplier<any>) => ContextMenuItem[] = links => {
+  return links.getLinks().map(link => {
+    return {
+      label: link.title,
+      // TODO: rename to href
+      url: link.href,
+      target: link.target,
+      icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
+    };
+  });
+};

+ 41 - 24
packages/grafana-ui/src/utils/fieldDisplay.ts

@@ -6,6 +6,7 @@ import {
   FieldConfig,
   DisplayValue,
   GraphSeriesValue,
+  DataFrameView,
 } from '@grafana/data';
 
 import toNumber from 'lodash/toNumber';
@@ -14,6 +15,7 @@ import toString from 'lodash/toString';
 import { GrafanaTheme, InterpolateFunction, ScopedVars } 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
@@ -23,7 +25,7 @@ export interface FieldDisplayOptions {
   defaults: FieldConfig; // Use these values unless otherwise stated
   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_CALC = '__calc';
@@ -59,10 +61,15 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat
 }
 
 export interface FieldDisplay {
-  name: string; // NOT title!
+  name: string; // The field name (title is in display)
   field: FieldConfig;
   display: DisplayValue;
   sparkline?: GraphSeriesValue[][];
+
+  // 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)
 }
 
 export interface GetFieldDisplayValuesOptions {
@@ -75,8 +82,19 @@ export interface GetFieldDisplayValuesOptions {
 
 export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
 
+const getTimeColumnIdx = (series: DataFrame) => {
+  let timeColumn = -1;
+  for (let i = 0; i < series.fields.length; i++) {
+    if (series.fields[i].type === FieldType.time) {
+      timeColumn = i;
+      break;
+    }
+  }
+  return timeColumn;
+};
+
 export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
-  const { data, replaceVariables, fieldOptions, sparkline } = options;
+  const { data, replaceVariables, fieldOptions } = options;
   const { defaults, override } = fieldOptions;
   const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last];
 
@@ -96,17 +114,11 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
           name: series.refId ? series.refId : `Series[${s}]`,
         };
       }
-      scopedVars[VAR_SERIES_NAME] = { text: 'Series', value: series.name };
-
-      let timeColumn = -1;
-      if (sparkline) {
-        for (let i = 0; i < series.fields.length; i++) {
-          if (series.fields[i].type === FieldType.time) {
-            timeColumn = i;
-            break;
-          }
-        }
-      }
+
+      scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name };
+
+      const timeColumn = getTimeColumnIdx(series);
+      const view = new DataFrameView(series);
 
       for (let i = 0; i < series.fields.length && !hitLimit; i++) {
         const field = series.fields[i];
@@ -131,7 +143,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
 
         const title = config.title ? config.title : defaultTitle;
 
-        // Show all number fields
+        // Show all rows
         if (fieldOptions.values) {
           const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0;
 
@@ -154,6 +166,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
               name,
               field: config,
               display: displayValue,
+              view,
+              column: i,
+              row: j,
             });
 
             if (values.length >= limit) {
@@ -166,15 +181,15 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
             field,
             reducers: calcs, // The stats to calculate
           });
+          let sparkline: GraphSeriesValue[][] | undefined = undefined;
 
-          // Single sparkline for a field
-          const points =
-            timeColumn < 0
-              ? undefined
-              : getFlotPairs({
-                  xField: series.fields[timeColumn],
-                  yField: series.fields[i],
-                });
+          // Single sparkline for every reducer
+          if (options.sparkline && timeColumn >= 0) {
+            sparkline = getFlotPairs({
+              xField: series.fields[timeColumn],
+              yField: series.fields[i],
+            });
+          }
 
           for (const calc of calcs) {
             scopedVars[VAR_CALC] = { value: calc, text: calc };
@@ -184,7 +199,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
               name,
               field: config,
               display: displayValue,
-              sparkline: points,
+              sparkline,
+              view,
+              column: i,
             });
           }
         }

+ 1 - 0
packages/grafana-ui/src/utils/index.ts

@@ -6,6 +6,7 @@ export * from './fieldDisplay';
 export * from './validate';
 export { getFlotPairs } from './flotPairs';
 export * from './slate';
+export * from './dataLinks';
 export { default as ansicolor } from './ansicolor';
 
 // Export with a namespace

+ 2 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx

@@ -11,6 +11,7 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
 import { PanelModel } from 'app/features/dashboard/state/PanelModel';
 import { ClickOutsideWrapper } from '@grafana/ui';
 import { DataLink } from '@grafana/data';
+import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
 
 export interface Props {
   panel: PanelModel;
@@ -88,7 +89,7 @@ export class PanelHeader extends Component<Props, State> {
             title={panel.title}
             description={panel.description}
             scopedVars={panel.scopedVars}
-            links={panel.links}
+            links={getPanelLinksSupplier(panel)}
             error={error}
           />
           <div

+ 1 - 8
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx

@@ -6,14 +6,7 @@ import { PanelModel } from '../../state';
 describe('Render', () => {
   it('should render component', () => {
     const panel = new PanelModel({});
-    const links: any[] = [
-      {
-        url: 'asd',
-        title: 'asd',
-      },
-    ];
-
-    const wrapper = shallow(<PanelHeaderCorner panel={panel} links={links} />);
+    const wrapper = shallow(<PanelHeaderCorner panel={panel} />);
     const instance = wrapper.instance() as PanelHeaderCorner;
 
     expect(instance.getInfoContent()).toBeDefined();

+ 7 - 10
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx

@@ -1,12 +1,10 @@
 import React, { Component } from 'react';
 
-import { renderMarkdown } from '@grafana/data';
+import { renderMarkdown, LinkModelSupplier } from '@grafana/data';
 import { Tooltip, ScopedVars, PopoverContent } from '@grafana/ui';
-import { DataLink } from '@grafana/data';
 
 import { PanelModel } from 'app/features/dashboard/state/PanelModel';
 import templateSrv from 'app/features/templating/template_srv';
-import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
 import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 
 enum InfoMode {
@@ -20,7 +18,7 @@ interface Props {
   title?: string;
   description?: string;
   scopedVars?: ScopedVars;
-  links?: DataLink[];
+  links?: LinkModelSupplier<PanelModel>;
   error?: string;
 }
 
@@ -45,22 +43,21 @@ export class PanelHeaderCorner extends Component<Props> {
   getInfoContent = (): JSX.Element => {
     const { panel } = this.props;
     const markdown = panel.description || '';
-    const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
     const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
     const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown);
+    const links = this.props.links && this.props.links.getLinks(panel);
 
     return (
       <div className="panel-info-content markdown-html">
         <div dangerouslySetInnerHTML={{ __html: markedInterpolatedMarkdown }} />
 
-        {panel.links && panel.links.length > 0 && (
+        {links && links.length > 0 && (
           <ul className="panel-info-corner-links">
-            {panel.links.map((link, idx) => {
-              const info = linkSrv.getDataLinkUIModel(link, panel.scopedVars);
+            {links.map((link, idx) => {
               return (
                 <li key={idx}>
-                  <a className="panel-info-corner-links__item" href={info.href} target={info.target}>
-                    {info.title}
+                  <a className="panel-info-corner-links__item" href={link.href} target={link.target}>
+                    {link.title}
                   </a>
                 </li>
               );

+ 1 - 1
public/app/features/dashboard/state/DashboardMigrator.test.ts

@@ -3,7 +3,7 @@ import { DashboardModel } from '../state/DashboardModel';
 import { PanelModel } from '../state/PanelModel';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
 import { expect } from 'test/lib/common';
-import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv';
+import { DataLinkBuiltInVars } from '@grafana/ui';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 

+ 1 - 1
public/app/features/dashboard/state/DashboardMigrator.ts

@@ -20,7 +20,7 @@ import {
   MIN_PANEL_HEIGHT,
   DEFAULT_PANEL_SPAN,
 } from 'app/core/constants';
-import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv';
+import { DataLinkBuiltInVars } from '@grafana/ui';
 
 export class DashboardMigrator {
   dashboard: DashboardModel;

+ 8 - 8
public/app/features/panel/panel_ctrl.ts

@@ -18,8 +18,8 @@ import {
 import { GRID_COLUMN_COUNT } from 'app/core/constants';
 import { auto } from 'angular';
 import { TemplateSrv } from '../templating/template_srv';
-import { LinkSrv } from './panellinks/link_srv';
 import { PanelPluginMeta } from '@grafana/ui/src/types/panel';
+import { getPanelLinksSupplier } from './panellinks/linkSuppliers';
 
 export class PanelCtrl {
   panel: any;
@@ -255,31 +255,31 @@ export class PanelCtrl {
       markdown = this.error || this.panel.description || '';
     }
 
-    const linkSrv: LinkSrv = this.$injector.get('linkSrv');
     const templateSrv: TemplateSrv = this.$injector.get('templateSrv');
     const interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
     let html = '<div class="markdown-html panel-info-content">';
 
     const md = renderMarkdown(interpolatedMarkdown);
     html += config.disableSanitizeHtml ? md : sanitize(md);
+    const links = this.panel.links && getPanelLinksSupplier(this.panel).getLinks();
 
-    if (this.panel.links && this.panel.links.length > 0) {
+    if (links && links.length > 0) {
       html += '<ul class="panel-info-corner-links">';
-      for (const link of this.panel.links) {
-        const info = linkSrv.getDataLinkUIModel(link, this.panel.scopedVars);
+      for (const link of links) {
         html +=
           '<li><a class="panel-menu-link" href="' +
-          escapeHtml(info.href) +
+          escapeHtml(link.href) +
           '" target="' +
-          escapeHtml(info.target) +
+          escapeHtml(link.target) +
           '">' +
-          escapeHtml(info.title) +
+          escapeHtml(link.title) +
           '</a></li>';
       }
       html += '</ul>';
     }
 
     html += '</div>';
+
     return html;
   }
 

+ 66 - 0
public/app/features/panel/panellinks/linkSuppliers.ts

@@ -0,0 +1,66 @@
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
+import { FieldDisplay, ScopedVars, DataLinkBuiltInVars } from '@grafana/ui';
+import { LinkModelSupplier, DataFrameHelper, FieldType } from '@grafana/data';
+import { getLinkSrv } from './link_srv';
+
+/**
+ * Link suppliers creates link models based on a link origin
+ */
+
+export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier<FieldDisplay> | undefined => {
+  const links = value.field.links;
+  if (!links || links.length === 0) {
+    return undefined;
+  }
+  return {
+    getLinks: (_scopedVars?: any) => {
+      const scopedVars: ScopedVars = {};
+      // TODO, add values to scopedVars and/or pass objects to event listeners
+      if (value.view) {
+        scopedVars[DataLinkBuiltInVars.seriesName] = {
+          text: 'Series',
+          value: value.view.dataFrame.name,
+        };
+        const field = value.column ? value.view.dataFrame.fields[value.column] : undefined;
+        if (field) {
+          console.log('Full Field Info:', field);
+        }
+        if (value.row) {
+          const row = value.view.get(value.row);
+          console.log('ROW:', row);
+          const dataFrame = new DataFrameHelper(value.view.dataFrame);
+
+          const timeField = dataFrame.getFirstFieldOfType(FieldType.time);
+          if (timeField) {
+            scopedVars[DataLinkBuiltInVars.valueTime] = {
+              text: 'Value time',
+              value: timeField.values.get(value.row),
+            };
+          }
+        }
+      } else {
+        console.log('VALUE', value);
+      }
+
+      return links.map(link => {
+        return getLinkSrv().getDataLinkUIModel(link, scopedVars, value);
+      });
+    },
+  };
+};
+
+export const getPanelLinksSupplier = (value: PanelModel): LinkModelSupplier<PanelModel> => {
+  const links = value.links;
+
+  if (!links || links.length === 0) {
+    return undefined;
+  }
+
+  return {
+    getLinks: () => {
+      return links.map(link => {
+        return getLinkSrv().getDataLinkUIModel(link, value.scopedVars, value);
+      });
+    },
+  };
+};

+ 15 - 47
public/app/features/panel/panellinks/link_srv.ts

@@ -3,15 +3,8 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 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, ScopedVars, VariableOrigin } from '@grafana/ui';
-import { TimeSeriesValue, DateTime, dateTime, DataLink, KeyValue, deprecationWarning } from '@grafana/data';
-
-export const DataLinkBuiltInVars = {
-  keepTime: '__url_time_range',
-  includeVars: '__all_variables',
-  seriesName: '__series_name',
-  valueTime: '__value_time',
-};
+import { VariableSuggestion, ScopedVars, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
+import { DataLink, KeyValue, deprecationWarning, LinkModel } from '@grafana/data';
 
 export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
   ...templateSrv.variables.map(variable => ({
@@ -44,22 +37,17 @@ export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [
   },
 ];
 
-type LinkTarget = '_blank' | '_self';
-
-export interface LinkModel {
-  href: string;
-  title: string;
-  target: LinkTarget;
-}
+export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [
+  ...getPanelLinksVariableSuggestions(),
+  {
+    value: `${DataLinkBuiltInVars.seriesName}`,
+    documentation: 'Adds series name',
+    origin: VariableOrigin.BuiltIn,
+  },
+];
 
-interface LinkDataPoint {
-  datapoint: TimeSeriesValue[];
-  seriesName: string;
-  [key: number]: any;
-}
 export interface LinkService {
-  getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => LinkModel;
-  getDataPointVars: (seriesName: string, dataPointTs: DateTime) => ScopedVars;
+  getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>;
 }
 
 export class LinkSrv implements LinkService {
@@ -90,33 +78,20 @@ export class LinkSrv implements LinkService {
     return info;
   }
 
-  getDataPointVars = (seriesName: string, valueTime: DateTime) => {
-    return {
-      [DataLinkBuiltInVars.valueTime]: {
-        text: valueTime.valueOf(),
-        value: valueTime.valueOf(),
-      },
-      [DataLinkBuiltInVars.seriesName]: {
-        text: seriesName,
-        value: seriesName,
-      },
-    };
-  };
-
-  getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => {
+  getDataLinkUIModel = <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => {
     const params: KeyValue = {};
     const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
 
-    const info: LinkModel = {
+    const info: LinkModel<T> = {
       href: link.url,
       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, {
       ...scopedVars,
       [DataLinkBuiltInVars.keepTime]: {
@@ -129,13 +104,6 @@ export class LinkSrv implements LinkService {
       },
     });
 
-    if (dataPoint) {
-      info.href = this.templateSrv.replace(
-        info.href,
-        this.getDataPointVars(dataPoint.seriesName, dateTime(dataPoint.datapoint[0]))
-      );
-    }
-
     return info;
   };
 
@@ -146,7 +114,7 @@ export class LinkSrv implements LinkService {
    */
   getPanelLinkAnchorInfo(link: DataLink, scopedVars: ScopedVars) {
     deprecationWarning('link_srv.ts', 'getPanelLinkAnchorInfo', 'getDataLinkUIModel');
-    return this.getDataLinkUIModel(link, scopedVars);
+    return this.getDataLinkUIModel(link, scopedVars, {});
   }
 }
 

+ 20 - 7
public/app/features/panel/panellinks/specs/link_srv.test.ts

@@ -1,4 +1,5 @@
-import { LinkSrv, DataLinkBuiltInVars } from '../link_srv';
+import { LinkSrv } from '../link_srv';
+import { DataLinkBuiltInVars } from '@grafana/ui';
 import _ from 'lodash';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -80,6 +81,7 @@ describe('linkSrv', () => {
             title: 'Any title',
             url: `/d/1?$${DataLinkBuiltInVars.keepTime}`,
           },
+          {},
           {}
         ).href
       ).toEqual('/d/1?from=now-1h&to=now');
@@ -92,32 +94,43 @@ describe('linkSrv', () => {
             title: 'Any title',
             url: `/d/1?$${DataLinkBuiltInVars.includeVars}`,
           },
+          {},
           {}
         ).href
       ).toEqual('/d/1?var-test1=val1&var-test2=val2');
     });
 
-    it('should interpolate series name from datapoint', () => {
+    it('should interpolate series name', () => {
       expect(
         linkSrv.getDataLinkUIModel(
           {
             title: 'Any title',
             url: `/d/1?var-test=$${DataLinkBuiltInVars.seriesName}`,
           },
-          {},
-          dataPointMock
+          {
+            [DataLinkBuiltInVars.seriesName]: {
+              value: 'A-series',
+              text: 'A-series',
+            },
+          },
+          {}
         ).href
       ).toEqual('/d/1?var-test=A-series');
     });
-    it('should interpolate time range based on datapoint timestamp', () => {
+    it('should interpolate value time', () => {
       expect(
         linkSrv.getDataLinkUIModel(
           {
             title: 'Any title',
             url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`,
           },
-          {},
-          dataPointMock
+          {
+            [DataLinkBuiltInVars.valueTime]: {
+              value: dataPointMock.datapoint[0],
+              text: dataPointMock.datapoint[0],
+            },
+          },
+          {}
         ).href
       ).toEqual('/d/1?time=1000000001');
     });

+ 22 - 13
public/app/plugins/panel/bargauge/BarGaugePanel.tsx

@@ -5,11 +5,12 @@ import React, { PureComponent } from 'react';
 import { config } from 'app/core/config';
 
 // Components
-import { BarGauge, VizRepeater, getFieldDisplayValues, FieldDisplay } from '@grafana/ui';
+import { BarGauge, VizRepeater, getFieldDisplayValues, FieldDisplay, DataLinksContextMenu } from '@grafana/ui';
 
 // Types
 import { BarGaugeOptions } from './types';
 import { PanelProps } from '@grafana/ui';
+import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
 
 export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
   renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
@@ -17,18 +18,26 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
     const { field, display } = value;
 
     return (
-      <BarGauge
-        value={display}
-        width={width}
-        height={height}
-        orientation={options.orientation}
-        thresholds={field.thresholds}
-        theme={config.theme}
-        itemSpacing={this.getItemSpacing()}
-        displayMode={options.displayMode}
-        minValue={field.min}
-        maxValue={field.max}
-      />
+      <DataLinksContextMenu links={getFieldLinksSupplier(value)}>
+        {({ openMenu, targetClassName }) => {
+          return (
+            <BarGauge
+              value={display}
+              width={width}
+              height={height}
+              orientation={options.orientation}
+              thresholds={field.thresholds}
+              theme={config.theme}
+              itemSpacing={this.getItemSpacing()}
+              displayMode={options.displayMode}
+              minValue={field.min}
+              maxValue={field.max}
+              onClick={openMenu}
+              className={targetClassName}
+            />
+          );
+        }}
+      </DataLinksContextMenu>
     );
   };
 

+ 24 - 1
public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx

@@ -12,11 +12,16 @@ import {
   FormLabel,
   PanelEditorProps,
   Select,
+  DataLinksEditor,
 } from '@grafana/ui';
-import { FieldConfig } from '@grafana/data';
+import { FieldConfig, DataLink } from '@grafana/data';
 
 import { Threshold, ValueMapping } from '@grafana/data';
 import { BarGaugeOptions, orientationOptions, displayModes } from './types';
+import {
+  getDataLinksVariableSuggestions,
+  getCalculationValueDataLinksVariableSuggestions,
+} from 'app/features/panel/panellinks/link_srv';
 
 export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) => {
@@ -51,11 +56,20 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
   onOrientationChange = ({ value }: any) => this.props.onOptionsChange({ ...this.props.options, orientation: value });
   onDisplayModeChange = ({ value }: any) => this.props.onOptionsChange({ ...this.props.options, displayMode: value });
 
+  onDataLinksChanged = (links: DataLink[]) => {
+    this.onDefaultsChange({
+      ...this.props.options.fieldOptions.defaults,
+      links,
+    });
+  };
   render() {
     const { options } = this.props;
     const { fieldOptions } = options;
     const { defaults } = fieldOptions;
 
+    const suggestions = fieldOptions.values
+      ? getDataLinksVariableSuggestions()
+      : getCalculationValueDataLinksVariableSuggestions();
     const labelWidth = 6;
 
     return (
@@ -92,6 +106,15 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
         </PanelOptionsGrid>
 
         <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
+
+        <PanelOptionsGroup title="Data links">
+          <DataLinksEditor
+            value={defaults.links}
+            onChange={this.onDataLinksChanged}
+            suggestions={suggestions}
+            maxLinks={10}
+          />
+        </PanelOptionsGroup>
       </>
     );
   }

+ 21 - 12
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -5,11 +5,12 @@ import React, { PureComponent } from 'react';
 import { config } from 'app/core/config';
 
 // Components
-import { Gauge, FieldDisplay, getFieldDisplayValues, VizOrientation } from '@grafana/ui';
+import { Gauge, FieldDisplay, getFieldDisplayValues, VizOrientation, DataLinksContextMenu } from '@grafana/ui';
 
 // Types
 import { GaugeOptions } from './types';
 import { PanelProps, VizRepeater } from '@grafana/ui';
+import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
 
 export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
   renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
@@ -17,17 +18,25 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
     const { field, display } = value;
 
     return (
-      <Gauge
-        value={display}
-        width={width}
-        height={height}
-        thresholds={field.thresholds}
-        showThresholdLabels={options.showThresholdLabels}
-        showThresholdMarkers={options.showThresholdMarkers}
-        minValue={field.min}
-        maxValue={field.max}
-        theme={config.theme}
-      />
+      <DataLinksContextMenu links={getFieldLinksSupplier(value)}>
+        {({ openMenu, targetClassName }) => {
+          return (
+            <Gauge
+              value={display}
+              width={width}
+              height={height}
+              thresholds={field.thresholds}
+              showThresholdLabels={options.showThresholdLabels}
+              showThresholdMarkers={options.showThresholdMarkers}
+              minValue={field.min}
+              maxValue={field.max}
+              theme={config.theme}
+              onClick={openMenu}
+              className={targetClassName}
+            />
+          );
+        }}
+      </DataLinksContextMenu>
     );
   };
 

+ 25 - 1
public/app/plugins/panel/gauge/GaugePanelEditor.tsx

@@ -10,10 +10,15 @@ import {
   FieldPropertiesEditor,
   Switch,
   PanelOptionsGroup,
+  DataLinksEditor,
 } from '@grafana/ui';
-import { Threshold, ValueMapping, FieldConfig } from '@grafana/data';
+import { Threshold, ValueMapping, FieldConfig, DataLink } from '@grafana/data';
 
 import { GaugeOptions } from './types';
+import {
+  getCalculationValueDataLinksVariableSuggestions,
+  getDataLinksVariableSuggestions,
+} from 'app/features/panel/panellinks/link_srv';
 
 export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
   labelWidth = 6;
@@ -56,10 +61,20 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
     });
   };
 
+  onDataLinksChanged = (links: DataLink[]) => {
+    this.onDefaultsChange({
+      ...this.props.options.fieldOptions.defaults,
+      links,
+    });
+  };
+
   render() {
     const { options } = this.props;
     const { fieldOptions, showThresholdLabels, showThresholdMarkers } = options;
     const { defaults } = fieldOptions;
+    const suggestions = fieldOptions.values
+      ? getDataLinksVariableSuggestions()
+      : getCalculationValueDataLinksVariableSuggestions();
 
     return (
       <>
@@ -92,6 +107,15 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
         </PanelOptionsGrid>
 
         <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
+
+        <PanelOptionsGroup title="Data links">
+          <DataLinksEditor
+            value={defaults.links}
+            onChange={this.onDataLinksChanged}
+            suggestions={suggestions}
+            maxLinks={10}
+          />
+        </PanelOptionsGroup>
       </>
     );
   }

+ 10 - 5
public/app/plugins/panel/graph/graph.ts

@@ -25,7 +25,7 @@ import ReactDOM from 'react-dom';
 import { GraphLegendProps, Legend } from './Legend/Legend';
 
 import { GraphCtrl } from './module';
-import { getValueFormat, ContextMenuItem, ContextMenuGroup } from '@grafana/ui';
+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';
@@ -196,10 +196,15 @@ class GraphElement {
           {
             items: [
               ...dataLinks.map<ContextMenuItem>(link => {
-                const linkUiModel = this.linkSrv.getDataLinkUIModel(link, this.panel.scopedVars, {
-                  seriesName: item.series.alias,
-                  datapoint: item.datapoint,
-                });
+                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,

+ 4 - 3
public/app/plugins/panel/singlestat/module.ts

@@ -24,9 +24,10 @@ import {
   DisplayValue,
   fieldReducers,
   KeyValue,
+  LinkModel,
 } from '@grafana/data';
 import { auto } from 'angular';
-import { LinkSrv, LinkModel } from 'app/features/panel/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
 import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner';
 import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
 
@@ -328,7 +329,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
     const $sanitize = this.$sanitize;
     const panel = ctrl.panel;
     const templateSrv = this.templateSrv;
-    let linkInfo: LinkModel | null = null;
+    let linkInfo: LinkModel<any> | null = null;
     const $panelContainer = elem.find('.panel-container');
     elem = elem.find('.singlestat-panel');
 
@@ -592,7 +593,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       elem.toggleClass('pointer', panel.links.length > 0);
 
       if (panel.links.length > 0) {
-        linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars);
+        linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars, {});
       } else {
         linkInfo = null;
       }

+ 25 - 1
public/app/plugins/panel/singlestat2/SingleStatEditor.tsx

@@ -9,13 +9,18 @@ import {
   FieldDisplayEditor,
   FieldPropertiesEditor,
   PanelOptionsGroup,
+  DataLinksEditor,
 } from '@grafana/ui';
-import { Threshold, ValueMapping, FieldConfig } from '@grafana/data';
+import { Threshold, ValueMapping, FieldConfig, DataLink } from '@grafana/data';
 
 import { SingleStatOptions, SparklineOptions } from './types';
 import { ColoringEditor } from './ColoringEditor';
 import { FontSizeEditor } from './FontSizeEditor';
 import { SparklineEditor } from './SparklineEditor';
+import {
+  getDataLinksVariableSuggestions,
+  getCalculationValueDataLinksVariableSuggestions,
+} from 'app/features/panel/panellinks/link_srv';
 
 export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
   onThresholdsChanged = (thresholds: Threshold[]) => {
@@ -53,10 +58,20 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
     });
   };
 
+  onDataLinksChanged = (links: DataLink[]) => {
+    this.onDefaultsChange({
+      ...this.props.options.fieldOptions.defaults,
+      links,
+    });
+  };
+
   render() {
     const { options } = this.props;
     const { fieldOptions } = options;
     const { defaults } = fieldOptions;
+    const suggestions = fieldOptions.values
+      ? getDataLinksVariableSuggestions()
+      : getCalculationValueDataLinksVariableSuggestions();
 
     return (
       <>
@@ -77,6 +92,15 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
         </PanelOptionsGrid>
 
         <ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
+
+        <PanelOptionsGroup title="Data links">
+          <DataLinksEditor
+            value={defaults.links}
+            onChange={this.onDataLinksChanged}
+            suggestions={suggestions}
+            maxLinks={10}
+          />
+        </PanelOptionsGroup>
       </>
     );
   }

+ 26 - 2
public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

@@ -6,8 +6,16 @@ import { config } from 'app/core/config';
 
 // Types
 import { SingleStatOptions } from './types';
-import { PanelProps, getFieldDisplayValues, VizRepeater, FieldDisplay, BigValue } from '@grafana/ui';
+import {
+  PanelProps,
+  getFieldDisplayValues,
+  VizRepeater,
+  FieldDisplay,
+  BigValue,
+  DataLinksContextMenu,
+} from '@grafana/ui';
 import { BigValueSparkline } from '@grafana/ui/src/components/BigValue/BigValue';
+import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
 
 export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
   renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
@@ -23,7 +31,23 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
       };
     }
 
-    return <BigValue value={value.display} sparkline={sparkline} width={width} height={height} theme={config.theme} />;
+    return (
+      <DataLinksContextMenu links={getFieldLinksSupplier(value)}>
+        {({ openMenu, targetClassName }) => {
+          return (
+            <BigValue
+              value={value.display}
+              sparkline={sparkline}
+              width={width}
+              height={height}
+              theme={config.theme}
+              onClick={openMenu}
+              className={targetClassName}
+            />
+          );
+        }}
+      </DataLinksContextMenu>
+    );
   };
 
   getValues = (): FieldDisplay[] => {