Przeglądaj źródła

Graph: Add data links feature (click on graph) (#17267)

* WIP: initial panel links editor

* WIP: Added dashboard migration to new panel drilldown link schema

* Make link_srv interpolate new variables

* Fix failing tests

* Drilldown: Add context menu to graph viz (#17284)

* Add simple context menu for adding graph annotations and showing drilldown links

* Close graph context menu when user start scrolling

* Move context menu component to grafana/ui

* Make graph context menu appear on click, use cmd/ctrl click for quick annotations

* Move graph context menu controller to separate file

* Drilldown: datapoint variables interpolation (#17328)

* Add simple context menu for adding graph annotations and showing drilldown links

* Close graph context menu when user start scrolling

* Move context menu component to grafana/ui

* Make graph context menu appear on click, use cmd/ctrl click for quick annotations

* Add util for absolute time range transformation

* Add series name and datapoint timestamp interpolation

* Rename drilldown link variables tot snake case, use const values instead of strings in tests

* Bring LinkSrv.getPanelLinkAnchorInfo for compatibility reasons and add deprecation warning

* Rename seriesLabel to seriesName

* Drilldown: use separate editors for panel and series links (#17355)

* Use correct target ini context menu links

* Rename PanelLinksEditor to DrilldownLinksEditor and mote it to grafana/ui

* Expose DrilldownLinksEditor as an angular directive

* Enable visualization specifix drilldown links

* Props interfaces rename

* Drilldown: Add variables suggestion and syntax highlighting for drilldown link editor (#17391)

* Add variables suggestion in drilldown link editor

* Enable prism

* Fix backspace not working

* Move slate value helpers to grafana/ui

* Add syntax higlighting for links input

* Rename drilldown link components to data links

* Add template variabe suggestions

* Bugfix

* Fix regexp not working in Firefox

* Display correct links in panel header corner

* bugfix

* bugfix

* Bugfix

* Context menu UI tweaks

* Use data link terminology instead of drilldown

* DataLinks: changed autocomplete syntax

* Use singular form for data link

* Use the same syntax higlighting for built-in and template variables in data links editor

* UI improvements to context menu

* UI review tweaks

* Tweak layout of data link editor

* Fix vertical spacing

* Remove data link header in context menu

* Remove pointer cursor from series label in context menu

* Fix variable selection on click

* DataLinks: migrations for old links

* Update docs about data links

* Use value time instead of time range when interpolating datapoint timestamp

* Remove not used util

* Update docs

* Moved icon a bit more down

* Interpolate value ts only when using __value_time variable

* Bring href property back to LinkModel

* Add any type annotations

* Fix TS error on slate's Value type

* minor changes
Torkel Ödegaard 6 lat temu
rodzic
commit
335cec07a5
48 zmienionych plików z 1586 dodań i 200 usunięć
  1. 25 10
      docs/sources/features/panels/graph.md
  2. 1 1
      packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts
  3. 261 0
      packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx
  4. 85 0
      packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx
  5. 200 0
      packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx
  6. 190 0
      packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx
  7. 77 0
      packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx
  8. 28 0
      packages/grafana-ui/src/components/DataLinks/SelectionReference.ts
  9. 3 2
      packages/grafana-ui/src/components/FormField/FormField.tsx
  10. 1 0
      packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx
  11. 0 1
      packages/grafana-ui/src/components/Switch/Switch.tsx
  12. 6 1
      packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
  13. 5 0
      packages/grafana-ui/src/components/index.ts
  14. 1 1
      packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts
  15. 6 0
      packages/grafana-ui/src/types/panel.ts
  16. 1 3
      packages/grafana-ui/src/utils/deprecationWarning.ts
  17. 1 0
      packages/grafana-ui/src/utils/index.ts
  18. 1 0
      packages/grafana-ui/src/utils/slate.ts
  19. 17 1
      public/app/core/angular_wrappers.ts
  20. 1 2
      public/app/core/utils/kbn.ts
  21. 16 0
      public/app/core/utils/url.ts
  22. 3 2
      public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
  23. 5 5
      public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap
  24. 2 2
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  25. 7 7
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
  26. 4 4
      public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap
  27. 26 2
      public/app/features/dashboard/panel_editor/GeneralTab.tsx
  28. 56 1
      public/app/features/dashboard/state/DashboardMigrator.test.ts
  29. 55 6
      public/app/features/dashboard/state/DashboardMigrator.ts
  30. 2 2
      public/app/features/dashboard/state/PanelModel.ts
  31. 1 1
      public/app/features/explore/QueryField.tsx
  32. 5 6
      public/app/features/panel/panel_ctrl.ts
  33. 133 80
      public/app/features/panel/panellinks/link_srv.ts
  34. 104 28
      public/app/features/panel/panellinks/specs/link_srv.test.ts
  35. 3 8
      public/app/features/panel/partials/general_tab.html
  36. 47 0
      public/app/plugins/panel/graph/GraphContextMenu.tsx
  37. 79 0
      public/app/plugins/panel/graph/GraphContextMenuCtrl.ts
  38. 73 14
      public/app/plugins/panel/graph/graph.ts
  39. 24 1
      public/app/plugins/panel/graph/module.ts
  40. 1 1
      public/app/plugins/panel/graph/specs/graph.test.ts
  41. 5 0
      public/app/plugins/panel/graph/tab_drilldown_links.html
  42. 9 0
      public/app/plugins/panel/graph/template.ts
  43. 1 1
      public/app/plugins/panel/singlestat/module.ts
  44. 4 2
      public/app/routes/GrafanaCtrl.ts
  45. 1 1
      public/sass/_variables.dark.generated.scss
  46. 2 2
      public/sass/components/_drop.scss
  47. 7 1
      public/sass/components/_panel_header.scss
  48. 1 1
      public/sass/components/_switch.scss

+ 25 - 10
docs/sources/features/panels/graph.md

@@ -35,20 +35,35 @@ The general tab allows customization of a panel's appearance and menu options.
 ### Repeat
 Repeat a panel for each value of a variable.  Repeating panels are described in more detail [here]({{< relref "../../reference/templating.md#repeating-panels" >}}).
 
-### Drilldown / detail link
+### Data link
 
-The drilldown section allows adding dynamic links to the panel that can link to other dashboards
-or URLs.
+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.
 
-Each link has a title, a type and params.  A link can be either a ``dashboard`` or ``absolute`` links.
-If it is a dashboard link, the `dashboard` value must be the name of a dashboard.  If it is an
-`absolute` link, the URL is the URL to the link.
+{{< docs-imagebox img="/img/docs/data_link.png"  max-width= "800px" >}}
 
-``params`` allows adding additional URL params to the links.  The format is the ``name=value`` with
-multiple params separated by ``&``.  Template variables can be added as values using ``$myvar``.
+Data link is defined by title, url and a setting whether or not it should be opened in a new window.
 
-When linking to another dashboard that uses template variables, you can use ``var-myvar=value`` to
-populate the template variable to a desired value from the link.
+**Title** is a human readable label for the link that will be displayed in the UI. The link itself is accessible in the graph's context menu when user **clicks on a single data point**:
+
+{{< docs-imagebox img="/img/docs/data_link_tooltip.png"  max-width= "800px" >}}
+
+**URL** field allows the URL configuration for a given link. Apart from regular query params it also supports built-in variables and dashboard variables that you can choose from
+available suggestions:
+
+{{< docs-imagebox img="/img/docs/data_link_typeahead.png"  max-width= "800px" >}}
+
+
+Available built-in variables are:
+
+1. ``__all_variables`` - will add all current dashboard's variables to the URL
+2. ``__url_time_range`` - will add current dashboard's time range to the URL (i.e. ``?from=now-6h&to=now``)
+3. ``__series_name`` - will add series name as a query param in the URL (i.e. ``?series=B-series``)
+4. ``__value_time`` - will add datapoint's timestamp (Unix ms epoch) to the URL (i.e. ``?time=1560268814105``)
+
+
+#### Template variables in data links
+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.
 
 ## Metrics
 

+ 1 - 1
packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts

@@ -1,4 +1,4 @@
-import deprecationWarning from '../../utils/deprecationWarning';
+import { deprecationWarning } from '../../utils/deprecationWarning';
 import { ColorPickerProps } from './ColorPickerPopover';
 
 export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {

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

@@ -0,0 +1,261 @@
+import React, { useContext, useRef } from 'react';
+import { css, cx } from 'emotion';
+import useClickAway from 'react-use/lib/useClickAway';
+import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
+import { Portal, List } from '../index';
+
+export interface ContextMenuItem {
+  label: string;
+  target?: string;
+  icon?: string;
+  url?: string;
+  onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
+  group?: string;
+}
+
+export interface ContextMenuGroup {
+  label?: string;
+  items: ContextMenuItem[];
+}
+export interface ContextMenuProps {
+  x: number;
+  y: number;
+  onClose: () => void;
+  items?: ContextMenuGroup[];
+  renderHeader?: () => JSX.Element;
+}
+
+const getContextMenuStyles = (theme: GrafanaTheme) => {
+  const linkColor = selectThemeVariant(
+    {
+      light: theme.colors.dark2,
+      dark: theme.colors.text,
+    },
+    theme.type
+  );
+  const linkColorHover = selectThemeVariant(
+    {
+      light: theme.colors.link,
+      dark: theme.colors.white,
+    },
+    theme.type
+  );
+  const wrapperBg = selectThemeVariant(
+    {
+      light: theme.colors.gray7,
+      dark: theme.colors.dark2,
+    },
+    theme.type
+  );
+  const wrapperShadow = selectThemeVariant(
+    {
+      light: theme.colors.gray3,
+      dark: theme.colors.black,
+    },
+    theme.type
+  );
+  const itemColor = selectThemeVariant(
+    {
+      light: theme.colors.black,
+      dark: theme.colors.white,
+    },
+    theme.type
+  );
+
+  const groupLabelColor = selectThemeVariant(
+    {
+      light: theme.colors.gray1,
+      dark: theme.colors.textWeak,
+    },
+    theme.type
+  );
+
+  const itemBgHover = selectThemeVariant(
+    {
+      light: theme.colors.gray5,
+      dark: theme.colors.dark7,
+    },
+    theme.type
+  );
+  const headerBg = selectThemeVariant(
+    {
+      light: theme.colors.white,
+      dark: theme.colors.dark1,
+    },
+    theme.type
+  );
+  const headerSeparator = selectThemeVariant(
+    {
+      light: theme.colors.white,
+      dark: theme.colors.dark7,
+    },
+    theme.type
+  );
+
+  return {
+    header: css`
+      padding: 4px;
+      border-bottom: 1px solid ${headerSeparator};
+      background: ${headerBg};
+      margin-bottom: ${theme.spacing.xs};
+      border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
+    `,
+    wrapper: css`
+      background: ${wrapperBg};
+      z-index: 1;
+      box-shadow: 0 2px 5px 0 ${wrapperShadow};
+      min-width: 200px;
+      border-radius: ${theme.border.radius.sm};
+    `,
+    link: css`
+      color: ${linkColor};
+      display: flex;
+      cursor: pointer;
+      &:hover {
+        color: ${linkColorHover};
+        text-decoration: none;
+      }
+    `,
+    item: css`
+      background: none;
+      padding: 4px 8px;
+      color: ${itemColor};
+      border-left: 2px solid transparent;
+      cursor: pointer;
+      &:hover {
+        background: ${itemBgHover};
+        border-image: linear-gradient(rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%);
+        border-image-slice: 1;
+      }
+    `,
+    groupLabel: css`
+      color: ${groupLabelColor};
+      font-size: ${theme.typography.size.sm};
+      line-height: ${theme.typography.lineHeight.lg};
+      padding: ${theme.spacing.xs} ${theme.spacing.sm};
+    `,
+    icon: css`
+      opacity: 0.7;
+      width: 12px;
+      height: 12px;
+      display: inline-block;
+      margin-right: 10px;
+      color: ${theme.colors.linkDisabled};
+      position: relative;
+      top: 4px;
+    `,
+  };
+};
+
+export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, items, renderHeader }) => {
+  const theme = useContext(ThemeContext);
+  const menuRef = useRef(null);
+  useClickAway(menuRef, () => {
+    if (onClose) {
+      onClose();
+    }
+  });
+
+  const styles = getContextMenuStyles(theme);
+
+  return (
+    <Portal>
+      <div
+        ref={menuRef}
+        style={{
+          position: 'fixed',
+          left: x - 5,
+          top: y + 5,
+        }}
+        className={styles.wrapper}
+      >
+        {renderHeader && <div className={styles.header}>{renderHeader()}</div>}
+        <List
+          items={items || []}
+          renderItem={(item, index) => {
+            return (
+              <>
+                <ContextMenuGroup group={item} onItemClick={onClose} />
+              </>
+            );
+          }}
+        />
+      </div>
+    </Portal>
+  );
+});
+
+interface ContextMenuItemProps {
+  label: string;
+  icon?: string;
+  url?: string;
+  target?: string;
+  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
+  className?: string;
+}
+
+const ContextMenuItem: React.FC<ContextMenuItemProps> = React.memo(
+  ({ url, icon, label, target, onClick, className }) => {
+    const theme = useContext(ThemeContext);
+    const styles = getContextMenuStyles(theme);
+    return (
+      <div className={styles.item}>
+        <a
+          href={url}
+          target={target || '_self'}
+          className={cx(className, styles.link)}
+          onClick={e => {
+            if (onClick) {
+              onClick(e);
+            }
+          }}
+        >
+          {icon && <i className={cx(`${icon}`, styles.icon)} />} {label}
+        </a>
+      </div>
+    );
+  }
+);
+
+interface ContextMenuGroupProps {
+  group: ContextMenuGroup;
+  onItemClick?: () => void;
+}
+
+const ContextMenuGroup: React.FC<ContextMenuGroupProps> = ({ group, onItemClick }) => {
+  const theme = useContext(ThemeContext);
+  const styles = getContextMenuStyles(theme);
+
+  if (group.items.length === 0) {
+    return null;
+  }
+
+  return (
+    <div>
+      {group.label && <div className={styles.groupLabel}>{group.label}</div>}
+      <List
+        items={group.items || []}
+        renderItem={item => {
+          return (
+            <ContextMenuItem
+              url={item.url}
+              label={item.label}
+              target={item.target}
+              icon={item.icon}
+              onClick={(e: React.MouseEvent<HTMLElement>) => {
+                if (item.onClick) {
+                  item.onClick(e);
+                }
+
+                if (onItemClick) {
+                  onItemClick();
+                }
+              }}
+            />
+          );
+        }}
+      />
+    </div>
+  );
+};
+ContextMenu.displayName = 'ContextMenu';

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

@@ -0,0 +1,85 @@
+import React, { useState, ChangeEvent, useContext } from 'react';
+import { DataLink } from '../../index';
+import { FormField, Switch } from '../index';
+import { VariableSuggestion } from './DataLinkSuggestions';
+import { css, cx } from 'emotion';
+import { ThemeContext } from '../../themes/index';
+import { DataLinkInput } from './DataLinkInput';
+
+interface DataLinkEditorProps {
+  index: number;
+  value: DataLink;
+  suggestions: VariableSuggestion[];
+  onChange: (index: number, link: DataLink) => void;
+  onRemove: (link: DataLink) => void;
+}
+
+export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
+  ({ index, value, onChange, onRemove, suggestions }) => {
+    const theme = useContext(ThemeContext);
+    const [title, setTitle] = useState(value.title);
+
+    const onUrlChange = (url: string) => {
+      onChange(index, { ...value, url });
+    };
+    const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
+      setTitle(event.target.value);
+    };
+
+    const onTitleBlur = () => {
+      onChange(index, { ...value, title: title });
+    };
+
+    const onRemoveClick = () => {
+      onRemove(value);
+    };
+
+    const onOpenInNewTabChanged = () => {
+      onChange(index, { ...value, targetBlank: !value.targetBlank });
+    };
+
+    return (
+      <div
+        className={cx(
+          'gf-form gf-form--inline',
+          css`
+            > * {
+              margin-right: ${theme.spacing.xs};
+              &:last-child {
+                margin-right: 0;
+              }
+            }
+          `
+        )}
+      >
+        <FormField
+          label="Title"
+          value={title}
+          onChange={onTitleChange}
+          onBlur={onTitleBlur}
+          inputWidth={15}
+          labelWidth={5}
+        />
+
+        <FormField
+          label="URL"
+          labelWidth={4}
+          inputEl={<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />}
+          className={css`
+            width: 100%;
+          `}
+        />
+
+        <Switch label="Open in new tab" checked={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
+
+        <div className="gf-form">
+          <button className="gf-form-label gf-form-label--btn" onClick={onRemoveClick}>
+            <i className="fa fa-times" />
+          </button>
+        </div>
+      </div>
+    );
+  }
+);
+
+DataLinkEditor.displayName = 'DataLinkEditor';

+ 200 - 0
packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx

@@ -0,0 +1,200 @@
+import React, { useState, useMemo, useCallback, useContext } from 'react';
+import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions';
+import { makeValue, ThemeContext } from '../../index';
+import { SelectionReference } from './SelectionReference';
+import { Portal } from '../index';
+// @ts-ignore
+import { Editor } from 'slate-react';
+// @ts-ignore
+import { Value, Change, Document } from 'slate';
+// @ts-ignore
+import Plain from 'slate-plain-serializer';
+import { Popper as ReactPopper } from 'react-popper';
+import useDebounce from 'react-use/lib/useDebounce';
+import { css, cx } from 'emotion';
+// @ts-ignore
+import PluginPrism from 'slate-prism';
+
+interface DataLinkInputProps {
+  value: string;
+  onChange: (url: string) => void;
+  suggestions: VariableSuggestion[];
+}
+
+const plugins = [
+  PluginPrism({
+    onlyIn: (node: any) => node.type === 'code_block',
+    getSyntax: () => 'links',
+  }),
+];
+
+export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, suggestions }) => {
+  const theme = useContext(ThemeContext);
+  const [showingSuggestions, setShowingSuggestions] = useState(false);
+  const [suggestionsIndex, setSuggestionsIndex] = useState(0);
+  const [usedSuggestions, setUsedSuggestions] = useState(
+    suggestions.filter(suggestion => {
+      return value.indexOf(suggestion.value) > -1;
+    })
+  );
+  // Using any here as TS has problem pickung up `change` method existance on Value
+  // According to code and documentation `change` is an instance method on Value in slate 0.33.8 that we use
+  // https://github.com/ianstormtaylor/slate/blob/slate%400.33.8/docs/reference/slate/value.md#change
+  const [linkUrl, setLinkUrl] = useState<any>(makeValue(value));
+
+  const getStyles = useCallback(() => {
+    return {
+      editor: css`
+        .token.builtInVariable {
+          color: ${theme.colors.queryGreen};
+        }
+        .token.variable {
+          color: ${theme.colors.queryKeyword};
+        }
+      `,
+    };
+  }, [theme]);
+
+  const currentSuggestions = useMemo(
+    () =>
+      suggestions.filter(suggestion => {
+        return usedSuggestions.map(s => s.value).indexOf(suggestion.value) === -1;
+      }),
+    [usedSuggestions, suggestions]
+  );
+
+  // SelectionReference is used to position the variables suggestion relatively to current DOM selection
+  const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions]);
+
+  // Keep track of variables that has been used already
+  const updateUsedSuggestions = () => {
+    const currentLink = Plain.serialize(linkUrl);
+    const next = usedSuggestions.filter(suggestion => {
+      return currentLink.indexOf(suggestion.value) > -1;
+    });
+    if (next.length !== usedSuggestions.length) {
+      setUsedSuggestions(next);
+    }
+  };
+
+  useDebounce(updateUsedSuggestions, 500, [linkUrl]);
+
+  const onKeyDown = (event: KeyboardEvent) => {
+    if (event.key === 'Backspace') {
+      setShowingSuggestions(false);
+      setSuggestionsIndex(0);
+    }
+
+    if (event.key === 'Enter') {
+      if (showingSuggestions) {
+        onVariableSelect(currentSuggestions[suggestionsIndex]);
+      }
+    }
+
+    if (showingSuggestions) {
+      if (event.key === 'ArrowDown') {
+        event.preventDefault();
+        setSuggestionsIndex(index => {
+          return (index + 1) % currentSuggestions.length;
+        });
+      }
+      if (event.key === 'ArrowUp') {
+        event.preventDefault();
+        setSuggestionsIndex(index => {
+          const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length;
+          return nextIndex;
+        });
+      }
+    }
+
+    if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
+      setShowingSuggestions(true);
+    }
+
+    if (event.key === 'Backspace') {
+      // @ts-ignore
+      return;
+    } else {
+      return true;
+    }
+  };
+
+  const onUrlChange = ({ value }: Change) => {
+    setLinkUrl(value);
+  };
+
+  const onUrlBlur = () => {
+    onChange(Plain.serialize(linkUrl));
+  };
+
+  const onVariableSelect = (item: VariableSuggestion) => {
+    const includeDollarSign = Plain.serialize(linkUrl).slice(-1) !== '$';
+
+    const change = linkUrl.change();
+
+    if (item.origin === VariableOrigin.BuiltIn) {
+      change.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
+    } else {
+      change.insertText(`var-${item.value}=$\{${item.value}}`);
+    }
+
+    setLinkUrl(change.value);
+    setShowingSuggestions(false);
+    setUsedSuggestions((previous: VariableSuggestion[]) => {
+      return [...previous, item];
+    });
+    setSuggestionsIndex(0);
+    onChange(Plain.serialize(change.value));
+  };
+  return (
+    <div
+      className={cx(
+        'gf-form-input',
+        css`
+          position: relative;
+          height: auto;
+        `
+      )}
+    >
+      <div className="slate-query-field">
+        {showingSuggestions && (
+          <Portal>
+            <ReactPopper
+              referenceElement={selectionRef}
+              placement="auto-end"
+              modifiers={{
+                preventOverflow: { enabled: true, boundariesElement: 'window' },
+                arrow: { enabled: false },
+                offset: { offset: 200 }, // width of the suggestions menu
+              }}
+            >
+              {({ ref, style, placement }) => {
+                return (
+                  <div ref={ref} style={style} data-placement={placement}>
+                    <DataLinkSuggestions
+                      suggestions={currentSuggestions}
+                      onSuggestionSelect={onVariableSelect}
+                      onClose={() => setShowingSuggestions(false)}
+                      activeIndex={suggestionsIndex}
+                    />
+                  </div>
+                );
+              }}
+            </ReactPopper>
+          </Portal>
+        )}
+        <Editor
+          placeholder="http://your-grafana.com/d/000000010/annotations"
+          value={linkUrl}
+          onChange={onUrlChange}
+          onBlur={onUrlBlur}
+          onKeyDown={onKeyDown}
+          plugins={plugins}
+          className={getStyles().editor}
+        />
+      </div>
+    </div>
+  );
+};
+
+DataLinkInput.displayName = 'DataLinkInput';

+ 190 - 0
packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx

@@ -0,0 +1,190 @@
+import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
+import { css, cx } from 'emotion';
+import React, { useRef, useContext, useMemo } from 'react';
+import useClickAway from 'react-use/lib/useClickAway';
+import { List } from '../index';
+
+export enum VariableOrigin {
+  BuiltIn = 'builtin',
+  Template = 'template',
+}
+
+export interface VariableSuggestion {
+  value: string;
+  documentation?: string;
+  origin: VariableOrigin;
+}
+
+interface DataLinkSuggestionsProps {
+  suggestions: VariableSuggestion[];
+  activeIndex: number;
+  onSuggestionSelect: (suggestion: VariableSuggestion) => void;
+  onClose?: () => void;
+}
+
+const getStyles = (theme: GrafanaTheme) => {
+  const wrapperBg = selectThemeVariant(
+    {
+      light: theme.colors.white,
+      dark: theme.colors.dark2,
+    },
+    theme.type
+  );
+
+  const wrapperShadow = selectThemeVariant(
+    {
+      light: theme.colors.gray5,
+      dark: theme.colors.black,
+    },
+    theme.type
+  );
+
+  const itemColor = selectThemeVariant(
+    {
+      light: theme.colors.black,
+      dark: theme.colors.white,
+    },
+    theme.type
+  );
+
+  const itemDocsColor = selectThemeVariant(
+    {
+      light: theme.colors.dark3,
+      dark: theme.colors.gray2,
+    },
+    theme.type
+  );
+
+  const itemBgHover = selectThemeVariant(
+    {
+      light: theme.colors.gray5,
+      dark: theme.colors.dark7,
+    },
+    theme.type
+  );
+
+  const itemBgActive = selectThemeVariant(
+    {
+      light: theme.colors.gray6,
+      dark: theme.colors.dark9,
+    },
+    theme.type
+  );
+
+  return {
+    wrapper: css`
+      background: ${wrapperBg};
+      z-index: 1;
+      width: 200px;
+      box-shadow: 0 5px 10px 0 ${wrapperShadow};
+    `,
+    item: css`
+      background: none;
+      padding: 4px 8px;
+      color: ${itemColor};
+      cursor: pointer;
+      &:hover {
+        background: ${itemBgHover};
+      }
+    `,
+    label: css`
+      color: ${theme.colors.textWeak};
+      font-size: ${theme.typography.size.sm};
+      line-height: ${theme.typography.lineHeight.lg};
+      padding: ${theme.spacing.sm};
+    `,
+    activeItem: css`
+      background: ${itemBgActive};
+      &:hover {
+        background: ${itemBgActive};
+      }
+    `,
+    itemValue: css`
+      font-family: ${theme.typography.fontFamily.monospace};
+    `,
+    itemDocs: css`
+      margin-top: ${theme.spacing.xs};
+      color: ${itemDocsColor};
+      font-size: ${theme.typography.size.sm};
+    `,
+  };
+};
+
+export const DataLinkSuggestions: React.FC<DataLinkSuggestionsProps> = ({ suggestions, ...otherProps }) => {
+  const ref = useRef(null);
+  const theme = useContext(ThemeContext);
+  useClickAway(ref, () => {
+    if (otherProps.onClose) {
+      otherProps.onClose();
+    }
+  });
+
+  const templateSuggestions = useMemo(() => {
+    return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.Template);
+  }, [suggestions]);
+
+  const builtInSuggestions = useMemo(() => {
+    return suggestions.filter(suggestion => suggestion.origin === VariableOrigin.BuiltIn);
+  }, [suggestions]);
+
+  const styles = getStyles(theme);
+  return (
+    <div ref={ref} className={styles.wrapper}>
+      {templateSuggestions.length > 0 && (
+        <DataLinkSuggestionsList
+          {...otherProps}
+          suggestions={templateSuggestions}
+          label="Template variables"
+          activeIndex={otherProps.activeIndex}
+          activeIndexOffset={0}
+        />
+      )}
+      {builtInSuggestions.length > 0 && (
+        <DataLinkSuggestionsList
+          {...otherProps}
+          suggestions={builtInSuggestions}
+          label="Built-in variables"
+          activeIndexOffset={templateSuggestions.length}
+        />
+      )}
+    </div>
+  );
+};
+
+DataLinkSuggestions.displayName = 'DataLinkSuggestions';
+
+interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps {
+  label: string;
+  activeIndexOffset: number;
+}
+
+const DataLinkSuggestionsList: React.FC<DataLinkSuggestionsListProps> = React.memo(
+  ({ activeIndex, activeIndexOffset, label, onClose, onSuggestionSelect, suggestions }) => {
+    const theme = useContext(ThemeContext);
+    const styles = getStyles(theme);
+
+    return (
+      <>
+        <div className={styles.label}>{label}</div>
+        <List
+          items={suggestions}
+          renderItem={(item, index) => {
+            return (
+              <div
+                className={cx(styles.item, index + activeIndexOffset === activeIndex && styles.activeItem)}
+                onClick={() => {
+                  onSuggestionSelect(item);
+                }}
+              >
+                <div className={styles.itemValue}>{item.value}</div>
+                {item.documentation && <div className={styles.itemDocs}>{item.documentation}</div>}
+              </div>
+            );
+          }}
+        />
+      </>
+    );
+  }
+);
+
+DataLinkSuggestionsList.displayName = 'DataLinkSuggestionsList';

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

@@ -0,0 +1,77 @@
+// Libraries
+import React, { FC, useContext } from 'react';
+// @ts-ignore
+import Prism from 'prismjs';
+// Components
+import { css } from 'emotion';
+import { DataLink, ThemeContext } from '../../index';
+import { Button } from '../index';
+import { DataLinkEditor } from './DataLinkEditor';
+import { VariableSuggestion } from './DataLinkSuggestions';
+
+interface DataLinksEditorProps {
+  value: DataLink[];
+  onChange: (links: DataLink[]) => void;
+  suggestions: VariableSuggestion[];
+  maxLinks?: number;
+}
+
+Prism.languages['links'] = {
+  builtInVariable: {
+    pattern: /(\${\w+})/,
+  },
+};
+
+export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, onChange, suggestions, maxLinks }) => {
+  const theme = useContext(ThemeContext);
+
+  const onAdd = () => {
+    onChange(value ? [...value, { url: '', title: '' }] : [{ url: '', title: '' }]);
+  };
+
+  const onLinkChanged = (linkIndex: number, newLink: DataLink) => {
+    onChange(
+      value.map((item, listIndex) => {
+        if (linkIndex === listIndex) {
+          return newLink;
+        }
+        return item;
+      })
+    );
+  };
+
+  const onRemove = (link: DataLink) => {
+    onChange(value.filter(item => item !== link));
+  };
+
+  return (
+    <>
+      {value && value.length > 0 && (
+        <div
+          className={css`
+            margin-bottom: ${theme.spacing.sm};
+          `}
+        >
+          {value.map((link, index) => (
+            <DataLinkEditor
+              key={index.toString()}
+              index={index}
+              value={link}
+              onChange={onLinkChanged}
+              onRemove={onRemove}
+              suggestions={suggestions}
+            />
+          ))}
+        </div>
+      )}
+
+      {(!value || (value && value.length < (maxLinks || 1))) && (
+        <Button variant="inverse" icon="fa fa-plus" onClick={() => onAdd()}>
+          Create link
+        </Button>
+      )}
+    </>
+  );
+});
+
+DataLinksEditor.displayName = 'DataLinksEditor';

+ 28 - 0
packages/grafana-ui/src/components/DataLinks/SelectionReference.ts

@@ -0,0 +1,28 @@
+export class SelectionReference {
+  getBoundingClientRect() {
+    const selection = window.getSelection();
+    const node = selection && selection.anchorNode;
+
+    if (node && node.parentElement) {
+      const rect = node.parentElement.getBoundingClientRect();
+      return rect;
+    }
+
+    return {
+      top: 0,
+      left: 0,
+      bottom: 0,
+      right: 0,
+      width: 0,
+      height: 0,
+    };
+  }
+
+  get clientWidth() {
+    return this.getBoundingClientRect().width;
+  }
+
+  get clientHeight() {
+    return this.getBoundingClientRect().height;
+  }
+}

+ 3 - 2
packages/grafana-ui/src/components/FormField/FormField.tsx

@@ -1,7 +1,7 @@
 import React, { InputHTMLAttributes, FunctionComponent } from 'react';
 import { FormLabel } from '../FormLabel/FormLabel';
 import { PopperContent } from '../Tooltip/PopperController';
-
+import { cx } from 'emotion';
 export interface Props extends InputHTMLAttributes<HTMLInputElement> {
   label: string;
   tooltip?: PopperContent<any>;
@@ -25,10 +25,11 @@ export const FormField: FunctionComponent<Props> = ({
   labelWidth,
   inputWidth,
   inputEl,
+  className,
   ...inputProps
 }) => {
   return (
-    <div className="form-field">
+    <div className={cx('form-field', className)}>
       <FormLabel width={labelWidth} tooltip={tooltip}>
         {label}
       </FormLabel>

+ 1 - 0
packages/grafana-ui/src/components/SingleStatShared/FieldPropertiesEditor.tsx

@@ -79,6 +79,7 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
       {'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
     </div>
   );
+
   return (
     <>
       <FormField

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

@@ -22,7 +22,6 @@ export class Switch extends PureComponent<Props, State> {
 
   internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
     event.stopPropagation();
-
     this.props.onChange(event);
   };
 

+ 6 - 1
packages/grafana-ui/src/components/Tooltip/_Tooltip.scss

@@ -7,6 +7,12 @@ $popper-margin-from-ref: 5px;
   .popper__arrow {
     border-color: $backgroundColor;
   }
+
+  code {
+    border: none;
+    background: darken($backgroundColor, 15%);
+    color: lighten($textColor, 20%);
+  }
 }
 
 .popper {
@@ -14,7 +20,6 @@ $popper-margin-from-ref: 5px;
   z-index: $zindex-tooltip;
   color: $tooltipColor;
   max-width: 400px;
-  text-align: center;
 }
 
 .popper__background {

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

@@ -6,6 +6,7 @@ export { Portal } from './Portal/Portal';
 export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
 
 export * from './Button/Button';
+export { ButtonVariant } from './Button/AbstractButton';
 
 // Select
 export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
@@ -65,3 +66,7 @@ export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
 export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
 export * from './SingleStatShared/index';
 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 { SeriesIcon } from './Legend/SeriesIcon';

+ 1 - 1
packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts

@@ -287,7 +287,7 @@ $popover-header-bg: $dark-9;
 $popover-shadow: 0 0 20px black;
 
 $popover-help-bg: $btn-secondary-bg;
-$popover-help-color: $text-color;
+$popover-help-color: $gray-6;
 
 $popover-error-bg: $btn-danger-bg;
 

+ 6 - 0
packages/grafana-ui/src/types/panel.ts

@@ -147,6 +147,12 @@ export interface RangeMap extends BaseMap {
   to: string;
 }
 
+export interface DataLink {
+  url: string;
+  title: string;
+  targetBlank?: boolean;
+}
+
 export enum VizOrientation {
   Auto = 'auto',
   Vertical = 'vertical',

+ 1 - 3
packages/grafana-ui/src/utils/deprecationWarning.ts

@@ -1,6 +1,4 @@
-const deprecationWarning = (file: string, oldName: string, newName: string) => {
+export const deprecationWarning = (file: string, oldName: string, newName: string) => {
   const message = `[Deprecation warning] ${file}: ${oldName} is deprecated. Use ${newName} instead`;
   console.warn(message);
 };
-
-export default deprecationWarning;

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

@@ -17,6 +17,7 @@ export { getFlotPairs } from './flotPairs';
 export * from './object';
 export * from './fieldCache';
 export * from './moment_wrapper';
+export * from './slate';
 
 // Names are too general to export
 // rangeutils, datemath

+ 1 - 0
public/app/features/explore/Value.ts → packages/grafana-ui/src/utils/slate.ts

@@ -1,3 +1,4 @@
+// @ts-ignore
 import { Block, Document, Text, Value } from 'slate';
 
 const SCHEMA = {

+ 17 - 1
public/app/core/angular_wrappers.ts

@@ -8,9 +8,10 @@ import { TagFilter } from './components/TagFilter/TagFilter';
 import { SideMenu } from './components/sidemenu/SideMenu';
 import { MetricSelect } from './components/Select/MetricSelect';
 import AppNotificationList from './components/AppNotifications/AppNotificationList';
-import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui';
+import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField, DataLinksEditor } from '@grafana/ui';
 import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
 import { SearchField } from './components/search/SearchField';
+import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu';
 
 export function registerAngularDirectives() {
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -72,4 +73,19 @@ export function registerAngularDirectives() {
     ['onReset', { watchDepth: 'reference', wrapApply: true }],
     ['onChange', { watchDepth: 'reference', wrapApply: true }],
   ]);
+  react2AngularDirective('graphContextMenu', GraphContextMenu, [
+    'x',
+    'y',
+    'items',
+    ['onClose', { watchDepth: 'reference', wrapApply: true }],
+    ['getContextMenuSource', { watchDepth: 'reference', wrapApply: true }],
+  ]);
+
+  // We keep the drilldown terminology here because of as using data-* directive
+  // being in conflict with HTML data attributes
+  react2AngularDirective('drilldownLinksEditor', DataLinksEditor, [
+    'value',
+    'suggestions',
+    ['onChange', { watchDepth: 'reference', wrapApply: true }],
+  ]);
 }

+ 1 - 2
public/app/core/utils/kbn.ts

@@ -1,7 +1,6 @@
 import { has } from 'lodash';
-import { getValueFormat, getValueFormatterIndex, getValueFormats } from '@grafana/ui';
+import { getValueFormat, getValueFormatterIndex, getValueFormats, deprecationWarning } from '@grafana/ui';
 import { stringToJsRegex } from '@grafana/data';
-import deprecationWarning from '@grafana/ui/src/utils/deprecationWarning';
 
 const kbn: any = {};
 

+ 16 - 0
public/app/core/utils/url.ts

@@ -71,3 +71,19 @@ export function toUrlParams(a: any) {
 
   return buildParams('', a).join('&');
 }
+
+export function appendQueryToUrl(url, stringToAppend) {
+  if (stringToAppend !== undefined && stringToAppend !== null && stringToAppend !== '') {
+    const pos = url.indexOf('?');
+    if (pos !== -1) {
+      if (url.length - pos > 1) {
+        url += '&';
+      }
+    } else {
+      url += '?';
+    }
+    url += stringToAppend;
+  }
+
+  return url;
+}

+ 3 - 2
public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts

@@ -1,6 +1,7 @@
 import angular from 'angular';
 import config from 'app/core/config';
 import { dateTime } from '@grafana/ui/src/utils/moment_wrapper';
+import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
 
 /** @ngInject */
 export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
@@ -72,13 +73,13 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
       delete params.fullscreen;
     }
 
-    $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
+    $scope.shareUrl = appendQueryToUrl(baseUrl, toUrlParams(params));
 
     let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
     soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
     delete params.fullscreen;
     delete params.edit;
-    soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
+    soloUrl = appendQueryToUrl(soloUrl, toUrlParams(params));
 
     $scope.iframeHtml = '<iframe src="' + soloUrl + '" width="450" height="200" frameborder="0"></iframe>';
 

+ 5 - 5
public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap

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

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

@@ -9,7 +9,7 @@ import templateSrv from 'app/features/templating/template_srv';
 
 import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
 import { PanelModel } from 'app/features/dashboard/state/PanelModel';
-import { ClickOutsideWrapper } from '@grafana/ui';
+import { ClickOutsideWrapper, DataLink } from '@grafana/ui';
 
 export interface Props {
   panel: PanelModel;
@@ -18,7 +18,7 @@ export interface Props {
   title?: string;
   description?: string;
   scopedVars?: ScopedVars;
-  links?: [];
+  links?: DataLink[];
   error?: string;
   isFullscreen: boolean;
 }

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

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 import Remarkable from 'remarkable';
-import { Tooltip, ScopedVars } from '@grafana/ui';
+import { Tooltip, ScopedVars, DataLink } from '@grafana/ui';
 
 import { PanelModel } from 'app/features/dashboard/state/PanelModel';
 import templateSrv from 'app/features/templating/template_srv';
@@ -18,7 +18,7 @@ interface Props {
   title?: string;
   description?: string;
   scopedVars?: ScopedVars;
-  links?: [];
+  links?: DataLink[];
   error?: string;
 }
 
@@ -48,15 +48,15 @@ export class PanelHeaderCorner extends Component<Props> {
     const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
 
     return (
-      <div className="panel-info-content markdown-html">
-        <div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
+      <div className="markdown-html panel-info-content">
+        <p dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
         {panel.links && panel.links.length > 0 && (
-          <ul className="text-left">
+          <ul className="panel-info-corner-links">
             {panel.links.map((link, idx) => {
-              const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
+              const info = linkSrv.getDataLinkUIModel(link, panel.scopedVars);
               return (
                 <li key={idx}>
-                  <a className="panel-menu-link" href={info.href} target={info.target}>
+                  <a className="panel-info-corner-links__item" href={info.href} target={info.target}>
                     {info.title}
                   </a>
                 </li>

+ 4 - 4
public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap

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

+ 26 - 2
public/app/features/dashboard/panel_editor/GeneralTab.tsx

@@ -1,10 +1,15 @@
+// Libraries
 import React, { PureComponent } from 'react';
 
+// Components
 import { getAngularLoader, AngularComponent } from '@grafana/runtime';
 import { EditorTabBody } from './EditorTabBody';
+import './../../panel/GeneralTabCtrl';
 
+// Types
 import { PanelModel } from '../state/PanelModel';
-import './../../panel/GeneralTabCtrl';
+import { DataLink, PanelOptionsGroup, DataLinksEditor } from '@grafana/ui';
+import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
 
 interface Props {
   panel: PanelModel;
@@ -42,10 +47,29 @@ export class GeneralTab extends PureComponent<Props> {
     }
   }
 
+  onDataLinksChanged = (links: DataLink[]) => {
+    this.props.panel.links = links;
+    this.props.panel.render();
+    this.forceUpdate();
+  };
+
   render() {
+    const { panel } = this.props;
+    const suggestions = getPanelLinksVariableSuggestions();
+
     return (
       <EditorTabBody heading="General" toolbarItems={[]}>
-        <div ref={element => (this.element = element)} />
+        <>
+          <div ref={element => (this.element = element)} />
+          <PanelOptionsGroup title="Panel links">
+            <DataLinksEditor
+              value={panel.links}
+              onChange={this.onDataLinksChanged}
+              suggestions={suggestions}
+              maxLinks={10}
+            />
+          </PanelOptionsGroup>
+        </>
       </EditorTabBody>
     );
   }

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

@@ -3,6 +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';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
@@ -127,7 +128,7 @@ describe('DashboardModel', () => {
     });
 
     it('dashboard schema version should be set to latest', () => {
-      expect(model.schemaVersion).toBe(18);
+      expect(model.schemaVersion).toBe(19);
     });
 
     it('graph thresholds should be migrated', () => {
@@ -382,6 +383,60 @@ describe('DashboardModel', () => {
       expect(dashboard.panels[0].maxPerRow).toBe(3);
     });
   });
+
+  describe('when migrating panel links', () => {
+    let model;
+
+    beforeEach(() => {
+      model = new DashboardModel({
+        panels: [
+          {
+            links: [
+              {
+                url: 'http://mylink.com',
+                keepTime: true,
+                title: 'test',
+              },
+              {
+                url: 'http://mylink.com?existingParam',
+                params: 'customParam',
+                title: 'test',
+              },
+              {
+                url: 'http://mylink.com?existingParam',
+                includeVars: true,
+                title: 'test',
+              },
+              {
+                dashboard: 'my other dashboard',
+                title: 'test',
+              },
+              {
+                dashUri: '',
+                title: 'test',
+              },
+            ],
+          },
+        ],
+      });
+    });
+
+    it('should add keepTime as variable', () => {
+      expect(model.panels[0].links[0].url).toBe(`http://mylink.com?$${DataLinkBuiltInVars.keepTime}`);
+    });
+
+    it('should add params to url', () => {
+      expect(model.panels[0].links[1].url).toBe('http://mylink.com?existingParam&customParam');
+    });
+
+    it('should add includeVars to url', () => {
+      expect(model.panels[0].links[2].url).toBe(`http://mylink.com?existingParam&$${DataLinkBuiltInVars.includeVars}`);
+    });
+
+    it('should slugify dashboard name', () => {
+      expect(model.panels[0].links[3].url).toBe(`/dashboard/db/my-other-dashboard`);
+    });
+  });
 });
 
 function createRow(options, panelDescriptions: any[]) {

+ 55 - 6
public/app/features/dashboard/state/DashboardMigrator.ts

@@ -1,4 +1,17 @@
+// Libraries
 import _ from 'lodash';
+
+// Utils
+import getFactors from 'app/core/utils/factors';
+import { appendQueryToUrl } from 'app/core/utils/url';
+import kbn from 'app/core/utils/kbn';
+
+// Types
+import { PanelModel } from './PanelModel';
+import { DashboardModel } from './DashboardModel';
+import { DataLink } from '@grafana/ui/src/types/panel';
+
+// Constants
 import {
   GRID_COLUMN_COUNT,
   GRID_CELL_HEIGHT,
@@ -7,9 +20,7 @@ import {
   MIN_PANEL_HEIGHT,
   DEFAULT_PANEL_SPAN,
 } from 'app/core/constants';
-import { PanelModel } from './PanelModel';
-import { DashboardModel } from './DashboardModel';
-import getFactors from 'app/core/utils/factors';
+import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv';
 
 export class DashboardMigrator {
   dashboard: DashboardModel;
@@ -18,11 +29,11 @@ export class DashboardMigrator {
     this.dashboard = dashboardModel;
   }
 
-  updateSchema(old) {
+  updateSchema(old: any) {
     let i, j, k, n;
     const oldVersion = this.dashboard.schemaVersion;
     const panelUpgrades = [];
-    this.dashboard.schemaVersion = 18;
+    this.dashboard.schemaVersion = 19;
 
     if (oldVersion === this.dashboard.schemaVersion) {
       return;
@@ -42,7 +53,6 @@ export class DashboardMigrator {
         if (panel.type === 'graphite') {
           panel.type = 'graph';
         }
-
         if (panel.type !== 'graph') {
           return;
         }
@@ -417,6 +427,15 @@ export class DashboardMigrator {
       });
     }
 
+    if (oldVersion < 19) {
+      // migrate change to gauge options
+      panelUpgrades.push(panel => {
+        if (panel.links && _.isArray(panel.links)) {
+          panel.links = panel.links.map(upgradePanelLink);
+        }
+      });
+    }
+
     if (panelUpgrades.length === 0) {
       return;
     }
@@ -612,3 +631,33 @@ class RowArea {
     return place;
   }
 }
+
+function upgradePanelLink(link: any): DataLink {
+  let url = link.url;
+
+  if (!url && link.dashboard) {
+    url = `/dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`;
+  }
+
+  if (!url && link.dashUri) {
+    url = `/dashboard/${link.dashUri}`;
+  }
+
+  if (link.keepTime) {
+    url = appendQueryToUrl(url, `$${DataLinkBuiltInVars.keepTime}`);
+  }
+
+  if (link.includeVars) {
+    url = appendQueryToUrl(url, `$${DataLinkBuiltInVars.includeVars}`);
+  }
+
+  if (link.params) {
+    url = appendQueryToUrl(url, link.params);
+  }
+
+  return {
+    url: url,
+    title: link.title,
+    targetBlank: link.targetBlank,
+  };
+}

+ 2 - 2
public/app/features/dashboard/state/PanelModel.ts

@@ -6,7 +6,7 @@ import { Emitter } from 'app/core/utils/emitter';
 import { getNextRefIdChar } from 'app/core/utils/query';
 
 // Types
-import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin } from '@grafana/ui';
+import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin, DataLink } from '@grafana/ui';
 import config from 'app/core/config';
 
 import { PanelQueryRunner } from './PanelQueryRunner';
@@ -106,7 +106,7 @@ export class PanelModel {
   maxDataPoints?: number;
   interval?: string;
   description?: string;
-  links?: [];
+  links?: DataLink[];
   transparent: boolean;
 
   // non persisted

+ 1 - 1
public/app/features/explore/QueryField.tsx

@@ -15,7 +15,7 @@ import ClearPlugin from './slate-plugins/clear';
 import NewlinePlugin from './slate-plugins/newline';
 
 import { TypeaheadWithTheme } from './Typeahead';
-import { makeFragment, makeValue } from './Value';
+import { makeFragment, makeValue } from '@grafana/ui';
 import PlaceholdersBuffer from './PlaceholdersBuffer';
 
 export const TYPEAHEAD_DEBOUNCE = 100;

+ 5 - 6
public/app/features/panel/panel_ctrl.ts

@@ -16,10 +16,10 @@ import {
 } from 'app/features/dashboard/utils/panel';
 
 import { GRID_COLUMN_COUNT } from 'app/core/constants';
-
 import { auto } from 'angular';
 import { TemplateSrv } from '../templating/template_srv';
 import { LinkSrv } from './panellinks/link_srv';
+
 export class PanelCtrl {
   panel: any;
   error: any;
@@ -257,16 +257,15 @@ export class PanelCtrl {
     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">';
+    let html = '<div class="markdown-html panel-info-content">';
 
     const md = new Remarkable().render(interpolatedMarkdown);
-    html += config.disableSanitizeHtml ? md : sanitize(md);
+    html += sanitize(md);
 
     if (this.panel.links && this.panel.links.length > 0) {
-      html += '<ul>';
+      html += '<ul class="panel-info-corner-links">';
       for (const link of this.panel.links) {
-        const info = linkSrv.getPanelLinkAnchorInfo(link, this.panel.scopedVars);
-
+        const info = linkSrv.getDataLinkUIModel(link, this.panel.scopedVars);
         html +=
           '<li><a class="panel-menu-link" href="' +
           escapeHtml(info.href) +

+ 133 - 80
public/app/features/panel/panellinks/link_srv.ts

@@ -1,11 +1,68 @@
-import angular from 'angular';
 import _ from 'lodash';
-import kbn from 'app/core/utils/kbn';
-import { TemplateSrv } from 'app/features/templating/template_srv';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
-import { ScopedVars } from '@grafana/ui/src/types/datasource';
+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 { DataLink, VariableSuggestion, KeyValue, ScopedVars, DateTime, dateTime } from '@grafana/ui';
+import { TimeSeriesValue } from '@grafana/ui';
+import { deprecationWarning, VariableOrigin } from '@grafana/ui';
+
+export const DataLinkBuiltInVars = {
+  keepTime: '__url_time_range',
+  includeVars: '__all_variables',
+  seriesName: '__series_name',
+  valueTime: '__value_time',
+};
+
+export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
+  ...templateSrv.variables.map(variable => ({
+    value: variable.name as string,
+    origin: VariableOrigin.Template,
+  })),
+  {
+    value: `${DataLinkBuiltInVars.includeVars}`,
+    documentation: 'Adds current variables',
+    origin: VariableOrigin.BuiltIn,
+  },
+  {
+    value: `${DataLinkBuiltInVars.keepTime}`,
+    documentation: 'Adds current time range',
+    origin: VariableOrigin.BuiltIn,
+  },
+];
+
+export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [
+  ...getPanelLinksVariableSuggestions(),
+  {
+    value: `${DataLinkBuiltInVars.seriesName}`,
+    documentation: 'Adds series name',
+    origin: VariableOrigin.BuiltIn,
+  },
+  {
+    value: `${DataLinkBuiltInVars.valueTime}`,
+    documentation: "Adds narrowed down time range relative to data point's timestamp",
+    origin: VariableOrigin.BuiltIn,
+  },
+];
+
+type LinkTarget = '_blank' | '_self';
+
+interface LinkModel {
+  href: string;
+  title: string;
+  target: LinkTarget;
+}
+
+interface LinkDataPoint {
+  datapoint: TimeSeriesValue[];
+  seriesName: string;
+}
+export interface LinkService {
+  getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => LinkModel;
+  getDataPointVars: (seriesName: string, dataPointTs: DateTime) => ScopedVars;
+}
 
-export class LinkSrv {
+export class LinkSrv implements LinkService {
   /** @ngInject */
   constructor(private templateSrv: TemplateSrv, private timeSrv: TimeSrv) {}
 
@@ -23,48 +80,7 @@ export class LinkSrv {
       this.templateSrv.fillVariableValuesForUrl(params);
     }
 
-    return this.addParamsToUrl(url, params);
-  }
-
-  addParamsToUrl(url: string, params: any) {
-    const paramsArray: Array<string | number> = [];
-
-    _.each(params, (value, key) => {
-      if (value === null) {
-        return;
-      }
-      if (value === true) {
-        paramsArray.push(key);
-      } else if (_.isArray(value)) {
-        _.each(value, instance => {
-          paramsArray.push(key + '=' + encodeURIComponent(instance));
-        });
-      } else {
-        paramsArray.push(key + '=' + encodeURIComponent(value));
-      }
-    });
-
-    if (paramsArray.length === 0) {
-      return url;
-    }
-
-    return this.appendToQueryString(url, paramsArray.join('&'));
-  }
-
-  appendToQueryString(url: string, stringToAppend: string) {
-    if (!_.isUndefined(stringToAppend) && stringToAppend !== null && stringToAppend !== '') {
-      const pos = url.indexOf('?');
-      if (pos !== -1) {
-        if (url.length - pos > 1) {
-          url += '&';
-        }
-      } else {
-        url += '?';
-      }
-      url += stringToAppend;
-    }
-
-    return url;
+    return appendQueryToUrl(url, toUrlParams(params));
   }
 
   getAnchorInfo(link: any) {
@@ -74,45 +90,82 @@ export class LinkSrv {
     return info;
   }
 
-  getPanelLinkAnchorInfo(link: any, scopedVars: ScopedVars) {
-    const info: any = {};
-    info.target = link.targetBlank ? '_blank' : '';
-    if (link.type === 'absolute') {
-      info.target = link.targetBlank ? '_blank' : '_self';
-      info.href = this.templateSrv.replace(link.url || '', scopedVars);
-      info.title = this.templateSrv.replace(link.title || '', scopedVars);
-    } else if (link.url) {
-      info.href = link.url;
-      info.title = this.templateSrv.replace(link.title || '', scopedVars);
-    } else if (link.dashUri) {
-      info.href = 'dashboard/' + link.dashUri + '?';
-      info.title = this.templateSrv.replace(link.title || '', scopedVars);
-    } else {
-      info.title = this.templateSrv.replace(link.title || '', scopedVars);
-      const slug = kbn.slugifyForUrl(link.dashboard || '');
-      info.href = 'dashboard/db/' + slug + '?';
-    }
-
-    const params: any = {};
-
-    if (link.keepTime) {
-      const range = this.timeSrv.timeRangeForUrl();
-      params['from'] = range.from;
-      params['to'] = range.to;
-    }
+  getDataPointVars = (seriesName: string, valueTime: DateTime) => {
+    // const valueTimeQuery = toUrlParams({
+    //   time: dateTime(valueTime).valueOf(),
+    // });
 
-    if (link.includeVars) {
-      this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
-    }
+    const seriesQuery = toUrlParams({
+      series: seriesName,
+    });
 
-    info.href = this.addParamsToUrl(info.href, params);
+    return {
+      [DataLinkBuiltInVars.valueTime]: {
+        text: valueTime.valueOf(),
+        value: valueTime.valueOf(),
+      },
+      [DataLinkBuiltInVars.seriesName]: {
+        text: seriesQuery,
+        value: seriesQuery,
+      },
+    };
+  };
+
+  getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => {
+    const params: KeyValue = {};
+    const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
+
+    const info: LinkModel = {
+      href: link.url,
+      title: this.templateSrv.replace(link.title || '', scopedVars),
+      target: link.targetBlank ? '_blank' : '_self',
+    };
+
+    this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
+
+    const variablesQuery = toUrlParams(params);
+
+    info.href = this.templateSrv.replace(link.url, {
+      ...scopedVars,
+      [DataLinkBuiltInVars.keepTime]: {
+        text: timeRangeUrl,
+        value: timeRangeUrl,
+      },
+      [DataLinkBuiltInVars.includeVars]: {
+        text: variablesQuery,
+        value: variablesQuery,
+      },
+    });
 
-    if (link.params) {
-      info.href = this.appendToQueryString(info.href, this.templateSrv.replace(link.params, scopedVars));
+    if (dataPoint) {
+      info.href = this.templateSrv.replace(
+        info.href,
+        this.getDataPointVars(dataPoint.seriesName, dateTime(dataPoint[0]))
+      );
     }
 
     return info;
+  };
+
+  /**
+   * getPanelLinkAnchorInfo method is left for plugins compatibility reasons
+   *
+   * @deprecated Drilldown links should be generated using getDataLinkUIModel method
+   */
+  getPanelLinkAnchorInfo(link: DataLink, scopedVars: ScopedVars) {
+    deprecationWarning('link_srv.ts', 'getPanelLinkAnchorInfo', 'getDataLinkUIModel');
+    return this.getDataLinkUIModel(link, scopedVars);
   }
 }
 
-angular.module('grafana.services').service('linkSrv', LinkSrv);
+let singleton: LinkService;
+
+export function setLinkSrv(srv: LinkService) {
+  singleton = srv;
+}
+
+export function getLinkSrv(): LinkService {
+  return singleton;
+}
+
+coreModule.service('linkSrv', LinkSrv);

+ 104 - 28
public/app/features/panel/panellinks/specs/link_srv.test.ts

@@ -1,49 +1,125 @@
-import { LinkSrv } from '../link_srv';
+import { LinkSrv, DataLinkBuiltInVars } from '../link_srv';
 import _ from 'lodash';
-import { TemplateSrv } from 'app/features/templating/template_srv';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
+import { TemplateSrv } from 'app/features/templating/template_srv';
+import { advanceTo } from 'jest-date-mock';
 
 jest.mock('angular', () => {
   const AngularJSMock = require('test/mocks/angular');
   return new AngularJSMock();
 });
 
+const dataPointMock = {
+  seriesName: 'A-series',
+  datapoint: [1000000000, 1],
+};
+
 describe('linkSrv', () => {
-  let linkSrv;
-  const templateSrvMock = {};
-  const timeSrvMock = {};
+  let linkSrv: LinkSrv;
+
+  function initLinkSrv() {
+    const rootScope = {
+      $on: jest.fn(),
+      onAppEvent: jest.fn(),
+      appEvent: jest.fn(),
+    };
+
+    const timer = {
+      register: jest.fn(),
+      cancel: jest.fn(),
+      cancelAll: jest.fn(),
+    };
+
+    const location = {
+      search: jest.fn(() => ({})),
+    };
+
+    const _dashboard: any = {
+      time: { from: 'now-6h', to: 'now' },
+      getTimezone: jest.fn(() => 'browser'),
+    };
+
+    const timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, {} as any);
+    timeSrv.init(_dashboard);
+    timeSrv.setTime({ from: 'now-1h', to: 'now' });
+    _dashboard.refresh = false;
+
+    const _templateSrv = new TemplateSrv();
+    _templateSrv.init([
+      {
+        type: 'query',
+        name: 'test1',
+        current: { value: 'val1' },
+        getValueForUrl: function() {
+          return this.current.value;
+        },
+      },
+      {
+        type: 'query',
+        name: 'test2',
+        current: { value: 'val2' },
+        getValueForUrl: function() {
+          return this.current.value;
+        },
+      },
+    ]);
+
+    linkSrv = new LinkSrv(_templateSrv, timeSrv);
+  }
 
   beforeEach(() => {
-    linkSrv = new LinkSrv(templateSrvMock as TemplateSrv, timeSrvMock as TimeSrv);
+    initLinkSrv();
+    advanceTo(1000000000);
   });
 
-  describe('when appending query strings', () => {
-    it('add ? to URL if not present', () => {
-      const url = linkSrv.appendToQueryString('http://example.com', 'foo=bar');
-      expect(url).toBe('http://example.com?foo=bar');
+  describe('built in variables', () => {
+    it('should add time range to url if $__url_time_range variable present', () => {
+      expect(
+        linkSrv.getDataLinkUIModel(
+          {
+            title: 'Any title',
+            url: `/d/1?$${DataLinkBuiltInVars.keepTime}`,
+          },
+          {}
+        ).href
+      ).toEqual('/d/1?from=now-1h&to=now');
     });
 
-    it('do not add & to URL if ? is present but query string is empty', () => {
-      const url = linkSrv.appendToQueryString('http://example.com?', 'foo=bar');
-      expect(url).toBe('http://example.com?foo=bar');
+    it('should add all variables to url if $__all_variables variable present', () => {
+      expect(
+        linkSrv.getDataLinkUIModel(
+          {
+            title: 'Any title',
+            url: `/d/1?$${DataLinkBuiltInVars.includeVars}`,
+          },
+          {}
+        ).href
+      ).toEqual('/d/1?var-test1=val1&var-test2=val2');
     });
 
-    it('add & to URL if query string is present', () => {
-      const url = linkSrv.appendToQueryString('http://example.com?foo=bar', 'hello=world');
-      expect(url).toBe('http://example.com?foo=bar&hello=world');
+    it('should interpolate series name from datapoint', () => {
+      expect(
+        linkSrv.getDataLinkUIModel(
+          {
+            title: 'Any title',
+            url: `/d/1?$${DataLinkBuiltInVars.seriesName}`,
+          },
+          {},
+          dataPointMock
+        ).href
+      ).toEqual('/d/1?series=A-series');
     });
-
-    it('do not change the URL if there is nothing to append', () => {
-      _.each(['', undefined, null], toAppend => {
-        const url1 = linkSrv.appendToQueryString('http://example.com', toAppend);
-        expect(url1).toBe('http://example.com');
-
-        const url2 = linkSrv.appendToQueryString('http://example.com?', toAppend);
-        expect(url2).toBe('http://example.com?');
-
-        const url3 = linkSrv.appendToQueryString('http://example.com?foo=bar', toAppend);
-        expect(url3).toBe('http://example.com?foo=bar');
-      });
+    it('should interpolate time range based on datapoint timestamp', () => {
+      expect(
+        linkSrv.getDataLinkUIModel(
+          {
+            title: 'Any title',
+            url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`,
+          },
+          {},
+          dataPointMock
+        ).href
+      ).toEqual('/d/1?time=1000000000');
     });
   });
 });

+ 3 - 8
public/app/features/panel/partials/general_tab.html

@@ -18,7 +18,9 @@
 </div>
 
 <div class="panel-options-group">
-  <div class="panel-options-group__header">Repeating</div>
+  <div class="panel-options-group__header">
+    <div class="panel-options-group__title">Repeating</div>
+  </div>
   <div class="panel-options-group__body">
     <div class="section">
       <div class="gf-form">
@@ -45,10 +47,3 @@
     </div>
   </div>
 </div>
-
-<div class="panel-options-group">
-  <div class="panel-options-group__header">Drilldown Links</div>
-  <div class="panel-options-group__body">
-    <panel-links-editor panel="ctrl.panel"></panel-links-editor>
-  </div>
-</div>

+ 47 - 0
public/app/plugins/panel/graph/GraphContextMenu.tsx

@@ -0,0 +1,47 @@
+import React, { useContext } from 'react';
+import { FlotDataPoint } from './GraphContextMenuCtrl';
+import { ContextMenu, ContextMenuProps, dateTime, SeriesIcon, ThemeContext } from '@grafana/ui';
+import { css } from 'emotion';
+
+type GraphContextMenuProps = ContextMenuProps & {
+  getContextMenuSource: () => FlotDataPoint | null;
+};
+
+export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({ getContextMenuSource, ...otherProps }) => {
+  const theme = useContext(ThemeContext);
+  const source = getContextMenuSource();
+
+  const renderHeader = source
+    ? () => {
+        if (!source) {
+          return null;
+        }
+
+        const timeFormat = source.series.hasMsResolution ? 'YYYY-MM-DD HH:mm:ss.SSS' : 'YYYY-MM-DD HH:mm:ss';
+
+        return (
+          <div
+            className={css`
+              padding: ${theme.spacing.xs} ${theme.spacing.sm};
+              font-size: ${theme.typography.size.sm};
+            `}
+          >
+            <strong>{dateTime(source.datapoint[0]).format(timeFormat)}</strong>
+            <div>
+              <SeriesIcon color={source.series.color} />
+              <span
+                className={css`
+                  white-space: nowrap;
+                  padding-left: ${theme.spacing.xs};
+                `}
+              >
+                {source.series.alias}
+              </span>
+            </div>
+          </div>
+        );
+      }
+    : null;
+
+  return <ContextMenu {...otherProps} renderHeader={renderHeader} />;
+};

+ 79 - 0
public/app/plugins/panel/graph/GraphContextMenuCtrl.ts

@@ -0,0 +1,79 @@
+import { ContextMenuItem } from '@grafana/ui';
+
+export interface FlotDataPoint {
+  dataIndex: number;
+  datapoint: number[];
+  pageX: number;
+  pageY: number;
+  series: any;
+  seriesIndex: number;
+}
+
+export class GraphContextMenuCtrl {
+  private source?: FlotDataPoint | null;
+  private scope?: any;
+  menuItems: ContextMenuItem[];
+  scrollContextElement: HTMLElement;
+  position: {
+    x: number;
+    y: number;
+  };
+
+  isVisible: boolean;
+
+  constructor($scope) {
+    this.isVisible = false;
+    this.menuItems = [];
+    this.scope = $scope;
+  }
+
+  onClose = () => {
+    if (this.scrollContextElement) {
+      this.scrollContextElement.removeEventListener('scroll', this.onClose);
+    }
+
+    this.scope.$apply(() => {
+      this.isVisible = false;
+    });
+  };
+
+  toggleMenu = (event?: { pageX: number; pageY: number }) => {
+    this.isVisible = !this.isVisible;
+    if (this.isVisible && this.scrollContextElement) {
+      this.scrollContextElement.addEventListener('scroll', this.onClose);
+    }
+
+    if (this.source) {
+      this.position = {
+        x: this.source.pageX,
+        y: this.source.pageY,
+      };
+    } else {
+      this.position = {
+        x: event ? event.pageX : 0,
+        y: event ? event.pageY : 0,
+      };
+    }
+  };
+
+  // Sets element which is considered as a scroll context of given context menu.
+  // Having access to this element allows scroll event attachement for menu to be closed when user scrolls
+  setScrollContextElement = (el: HTMLElement) => {
+    this.scrollContextElement = el;
+  };
+
+  setSource = (source: FlotDataPoint | null) => {
+    this.source = source;
+  };
+  getSource = () => {
+    return this.source;
+  };
+
+  setMenuItems = (items: ContextMenuItem[]) => {
+    this.menuItems = items;
+  };
+
+  getMenuItems = () => {
+    return this.menuItems;
+  };
+}

+ 73 - 14
public/app/plugins/panel/graph/graph.ts

@@ -16,22 +16,25 @@ import GraphTooltip from './graph_tooltip';
 import { ThresholdManager } from './threshold_manager';
 import { TimeRegionManager } from './time_region_manager';
 import { EventManager } from 'app/features/annotations/all';
+import { LinkService } from 'app/features/panel/panellinks/link_srv';
 import { convertToHistogramData } from './histogram';
 import { alignYLevel } from './align_yaxes';
 import config from 'app/core/config';
 import React from 'react';
 import ReactDOM from 'react-dom';
-import { Legend, GraphLegendProps } from './Legend/Legend';
+import { GraphLegendProps, Legend } from './Legend/Legend';
 
 import { GraphCtrl } from './module';
-import { getValueFormat } from '@grafana/ui';
+import { getValueFormat, ContextMenuItem, ContextMenuGroup, DataLink } from '@grafana/ui';
 import { provideTheme } from 'app/core/utils/ConfigProvider';
 import { toUtc } from '@grafana/ui/src/utils/moment_wrapper';
+import { GraphContextMenuCtrl, FlotDataPoint } from './GraphContextMenuCtrl';
 
 const LegendWithThemeProvider = provideTheme(Legend);
 
 class GraphElement {
   ctrl: GraphCtrl;
+  contextMenu: GraphContextMenuCtrl;
   tooltip: any;
   dashboard: any;
   annotations: object[];
@@ -45,8 +48,10 @@ class GraphElement {
   timeRegionManager: TimeRegionManager;
   legendElem: HTMLElement;
 
-  constructor(private scope, private elem, private timeSrv) {
+  // @ts-ignore
+  constructor(private scope, private elem, private timeSrv, private linkSrv: LinkService) {
     this.ctrl = scope.ctrl;
+    this.contextMenu = scope.ctrl.contextMenuCtrl;
     this.dashboard = this.ctrl.dashboard;
     this.panel = this.ctrl.panel;
     this.annotations = [];
@@ -113,7 +118,7 @@ class GraphElement {
     ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel());
   }
 
-  onGraphHover(evt) {
+  onGraphHover(evt: any) {
     // ignore other graph hover events if shared tooltip is disabled
     if (!this.dashboard.sharedTooltipModeEnabled()) {
       return;
@@ -143,13 +148,13 @@ class GraphElement {
     ReactDOM.unmountComponentAtNode(this.legendElem);
   }
 
-  onGraphHoverClear(event, info) {
+  onGraphHoverClear(event: any, info: any) {
     if (this.plot) {
       this.tooltip.clear(this.plot);
     }
   }
 
-  onPlotSelected(event: JQueryEventObject, ranges) {
+  onPlotSelected(event: JQueryEventObject, ranges: any) {
     if (this.panel.xaxis.mode !== 'time') {
       // Skip if panel in histogram or series mode
       this.plot.clearSelection();
@@ -171,7 +176,49 @@ class GraphElement {
     }
   }
 
-  onPlotClick(event: JQueryEventObject, pos, item) {
+  getContextMenuItems = (flotPosition: { x: number; y: number }, item?: FlotDataPoint): ContextMenuGroup[] => {
+    const dataLinks: DataLink[] = this.panel.options.dataLinks || [];
+
+    const items: ContextMenuGroup[] = [
+      {
+        items: [
+          {
+            label: 'Add annotation',
+            icon: 'gicon gicon-annotation',
+            onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
+          },
+        ],
+      },
+    ];
+
+    return item
+      ? [
+          ...items,
+          {
+            items: [
+              ...dataLinks.map<ContextMenuItem>(link => {
+                const linkUiModel = this.linkSrv.getDataLinkUIModel(link, this.panel.scopedVariables, {
+                  seriesName: item.series.alias,
+                  datapoint: item.datapoint,
+                });
+                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) {
+    const scrollContextElement = this.elem.closest('.view') ? this.elem.closest('.view').get()[0] : null;
+    const contextMenuSourceItem = item;
+    let contextMenuItems;
+
     if (this.panel.xaxis.mode !== 'time') {
       // Skip if panel in histogram or series mode
       return;
@@ -179,12 +226,23 @@ class GraphElement {
 
     if ((pos.ctrlKey || pos.metaKey) && (this.dashboard.meta.canEdit || this.dashboard.meta.canMakeEditable)) {
       // Skip if range selected (added in "plotselected" event handler)
-      const isRangeSelection = pos.x !== pos.x1;
-      if (!isRangeSelection) {
-        setTimeout(() => {
-          this.eventManager.updateTime({ from: pos.x, to: null });
-        }, 100);
+      if (pos.x !== pos.x1) {
+        return;
       }
+      setTimeout(() => {
+        this.eventManager.updateTime({ from: pos.x, to: null });
+      }, 100);
+      return;
+    } else {
+      this.tooltip.clear(this.plot);
+      contextMenuItems = this.getContextMenuItems(pos, item);
+      this.scope.$apply(() => {
+        // Setting nearest CustomScrollbar element as a scroll context for graph context menu
+        this.contextMenu.setScrollContextElement(scrollContextElement);
+        this.contextMenu.setSource(contextMenuSourceItem);
+        this.contextMenu.setMenuItems(contextMenuItems);
+        this.contextMenu.toggleMenu(pos);
+      });
     }
   }
 
@@ -447,6 +505,7 @@ class GraphElement {
         color: gridColor,
         margin: { left: 0, right: 0 },
         labelMarginX: 0,
+        mouseActiveRadius: 30,
       },
       selection: {
         mode: 'x',
@@ -787,12 +846,12 @@ class GraphElement {
 }
 
 /** @ngInject */
-function graphDirective(timeSrv, popoverSrv, contextSrv) {
+function graphDirective(timeSrv, popoverSrv, contextSrv, linkSrv) {
   return {
     restrict: 'A',
     template: '',
     link: (scope, elem) => {
-      return new GraphElement(scope, elem, timeSrv);
+      return new GraphElement(scope, elem, timeSrv, linkSrv);
     },
   };
 }

+ 24 - 1
public/app/plugins/panel/graph/module.ts

@@ -11,9 +11,11 @@ import { DataProcessor } from './data_processor';
 import { axesEditorComponent } from './axes_editor';
 import config from 'app/core/config';
 import TimeSeries from 'app/core/time_series2';
-import { getColorFromHexRgbOrName, LegacyResponseData, SeriesData } from '@grafana/ui';
+import { getColorFromHexRgbOrName, LegacyResponseData, SeriesData, DataLink, VariableSuggestion } from '@grafana/ui';
 import { getProcessedSeriesData } from 'app/features/dashboard/state/PanelQueryState';
 import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner';
+import { GraphContextMenuCtrl } from './GraphContextMenuCtrl';
+import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
 
 class GraphCtrl extends MetricsPanelCtrl {
   static template = template;
@@ -30,6 +32,8 @@ class GraphCtrl extends MetricsPanelCtrl {
   colors: any = [];
   subTabIndex: number;
   processor: DataProcessor;
+  contextMenuCtrl: GraphContextMenuCtrl;
+  linkVariableSuggestions: VariableSuggestion[] = getDataLinksVariableSuggestions();
 
   panelDefaults = {
     // datasource name, null = default datasource
@@ -120,6 +124,9 @@ class GraphCtrl extends MetricsPanelCtrl {
     seriesOverrides: [],
     thresholds: [],
     timeRegions: [],
+    options: {
+      dataLinks: [],
+    },
   };
 
   /** @ngInject */
@@ -130,9 +137,11 @@ class GraphCtrl extends MetricsPanelCtrl {
     _.defaults(this.panel.tooltip, this.panelDefaults.tooltip);
     _.defaults(this.panel.legend, this.panelDefaults.legend);
     _.defaults(this.panel.xaxis, this.panelDefaults.xaxis);
+    _.defaults(this.panel.options, this.panelDefaults.options);
 
     this.dataFormat = PanelQueryRunnerFormat.series;
     this.processor = new DataProcessor(this.panel);
+    this.contextMenuCtrl = new GraphContextMenuCtrl($scope);
 
     this.events.on('render', this.onRender.bind(this));
     this.events.on('data-received', this.onDataReceived.bind(this));
@@ -140,6 +149,8 @@ class GraphCtrl extends MetricsPanelCtrl {
     this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this));
     this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
     this.events.on('init-panel-actions', this.onInitPanelActions.bind(this));
+
+    this.onDataLinksChange = this.onDataLinksChange.bind(this);
   }
 
   onInitEditMode() {
@@ -147,6 +158,7 @@ class GraphCtrl extends MetricsPanelCtrl {
     this.addEditorTab('Axes', axesEditorComponent);
     this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html');
     this.addEditorTab('Thresholds & Time Regions', 'public/app/plugins/panel/graph/tab_thresholds_time_regions.html');
+    this.addEditorTab('Data link', 'public/app/plugins/panel/graph/tab_drilldown_links.html');
     this.subTabIndex = 0;
   }
 
@@ -284,6 +296,13 @@ class GraphCtrl extends MetricsPanelCtrl {
     this.render();
   };
 
+  onDataLinksChange(dataLinks: DataLink[]) {
+    this.panel.updateOptions({
+      ...this.panel.options,
+      dataLinks,
+    });
+  }
+
   addSeriesOverride(override) {
     this.panel.seriesOverrides.push(override || {});
   }
@@ -313,6 +332,10 @@ class GraphCtrl extends MetricsPanelCtrl {
       modalClass: 'modal--narrow',
     });
   }
+
+  onContextMenuClose = () => {
+    this.contextMenuCtrl.toggleMenu();
+  };
 }
 
 export { GraphCtrl, GraphCtrl as PanelCtrl };

+ 1 - 1
public/app/plugins/panel/graph/specs/graph.test.ts

@@ -118,7 +118,7 @@ describe('grafanaGraph', () => {
     $.plot = ctrl.plot = jest.fn();
     scope.ctrl = ctrl;
 
-    link = graphDirective({}, {}, {}).link(scope, { width: () => 500, mouseleave: () => {}, bind: () => {} });
+    link = graphDirective({}, {}, {}, {}).link(scope, { width: () => 500, mouseleave: () => {}, bind: () => {} });
     if (typeof beforeRender === 'function') {
       beforeRender();
     }

+ 5 - 0
public/app/plugins/panel/graph/tab_drilldown_links.html

@@ -0,0 +1,5 @@
+<drilldown-links-editor
+  value="ctrl.panel.options.dataLinks"
+  suggestions="ctrl.linkVariableSuggestions"
+  on-change="ctrl.onDataLinksChange"
+></drilldown-links-editor>

+ 9 - 0
public/app/plugins/panel/graph/template.ts

@@ -6,6 +6,15 @@ const template = `
   <div class="graph-legend">
     <div class="graph-legend-content" graph-legend></div>
   </div>
+  <div ng-if="ctrl.contextMenuCtrl.isVisible">
+    <graph-context-menu
+      items="ctrl.contextMenuCtrl.menuItems"
+      onClose="ctrl.onContextMenuClose"
+      getContextMenuSource="ctrl.contextMenuCtrl.getSource"
+      x="ctrl.contextMenuCtrl.position.x"
+      y="ctrl.contextMenuCtrl.position.y"
+    ></graph-context-menu>
+  </div>
 </div>
 `;
 

+ 1 - 1
public/app/plugins/panel/singlestat/module.ts

@@ -641,7 +641,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       elem.toggleClass('pointer', panel.links.length > 0);
 
       if (panel.links.length > 0) {
-        linkInfo = linkSrv.getPanelLinkAnchorInfo(panel.links[0], data.scopedVars);
+        linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars);
       } else {
         linkInfo = null;
       }

+ 4 - 2
public/app/routes/GrafanaCtrl.ts

@@ -21,11 +21,12 @@ import { updateLocation } from 'app/core/actions';
 
 // Types
 import { KioskUrlValue } from 'app/types';
+import { setLinkSrv, LinkSrv } from 'app/features/panel/panellinks/link_srv';
 import { UtilSrv } from 'app/core/services/util_srv';
 import { ContextSrv } from 'app/core/services/context_srv';
 import { BridgeSrv } from 'app/core/services/bridge_srv';
 import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
-import { ILocationService, ITimeoutService, IRootScopeService, IControllerService } from 'angular';
+import { ILocationService, ITimeoutService, IRootScopeService } from 'angular';
 
 export class GrafanaCtrl {
   /** @ngInject */
@@ -33,11 +34,11 @@ export class GrafanaCtrl {
     $scope: any,
     utilSrv: UtilSrv,
     $rootScope: any,
-    $controller: IControllerService,
     contextSrv: ContextSrv,
     bridgeSrv: BridgeSrv,
     backendSrv: BackendSrv,
     timeSrv: TimeSrv,
+    linkSrv: LinkSrv,
     datasourceSrv: DatasourceSrv,
     keybindingSrv: KeybindingSrv,
     angularLoader: AngularLoader
@@ -47,6 +48,7 @@ export class GrafanaCtrl {
     setBackendSrv(backendSrv);
     setDataSourceSrv(datasourceSrv);
     setTimeSrv(timeSrv);
+    setLinkSrv(linkSrv);
     setKeybindingSrv(keybindingSrv);
     const store = configureStore();
     setLocationSrv({

+ 1 - 1
public/sass/_variables.dark.generated.scss

@@ -290,7 +290,7 @@ $popover-header-bg: $dark-9;
 $popover-shadow: 0 0 20px black;
 
 $popover-help-bg: $btn-secondary-bg;
-$popover-help-color: $text-color;
+$popover-help-color: $gray-6;
 
 $popover-error-bg: $btn-danger-bg;
 

+ 2 - 2
public/sass/components/_drop.scss

@@ -38,9 +38,9 @@ $easing: cubic-bezier(0, 0, 0.265, 1);
 
 .drop-help {
   a {
-    color: $white;
+    color: $gray-6;
     &:hover {
-      color: darken($white, 10%);
+      color: $white;
     }
   }
 }

+ 7 - 1
public/sass/components/_panel_header.scss

@@ -169,11 +169,17 @@ $panel-header-no-title-zindex: 1;
 
 .panel-info-content {
   a {
-    color: $white;
+    color: $gray-6;
+
     &:hover {
       color: darken($white, 10%);
     }
   }
+
+  .panel-info-corner-links {
+    list-style: none;
+    padding-left: 0;
+  }
 }
 
 .panel-time-info {

+ 1 - 1
public/sass/components/_switch.scss

@@ -15,6 +15,7 @@ gf-form-switch[disabled] {
 
 .gf-form-switch-container-react {
   display: flex;
+  flex-shrink: 0;
 }
 
 .gf-form-switch-container {
@@ -33,7 +34,6 @@ gf-form-switch[disabled] {
   border-radius: $input-border-radius;
   align-items: center;
   justify-content: center;
-
   input {
     opacity: 0;
     width: 0;