mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
@@ -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,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
packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
261
packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
@@ -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';
|
||||
@@ -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
packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx
Normal file
200
packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx
Normal file
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -79,6 +79,7 @@ export const FieldPropertiesEditor: React.FC<Props> = ({ value, onChange, showMi
|
||||
{'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -22,7 +22,6 @@ export class Switch extends PureComponent<Props, State> {
|
||||
|
||||
internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.onChange(event);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import { Block, Document, Text, Value } from 'slate';
|
||||
|
||||
const SCHEMA = {
|
||||
@@ -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,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 = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>';
|
||||
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import { getAngularLoader, AngularComponent } from '@grafana/runtime';
|
||||
import { EditorTabBody } from './EditorTabBody';
|
||||
|
||||
import { PanelModel } from '../state/PanelModel';
|
||||
import './../../panel/GeneralTabCtrl';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../state/PanelModel';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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 class LinkSrv {
|
||||
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 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 + '?';
|
||||
}
|
||||
getDataPointVars = (seriesName: string, valueTime: DateTime) => {
|
||||
// const valueTimeQuery = toUrlParams({
|
||||
// time: dateTime(valueTime).valueOf(),
|
||||
// });
|
||||
|
||||
const params: any = {};
|
||||
const seriesQuery = toUrlParams({
|
||||
series: seriesName,
|
||||
});
|
||||
|
||||
if (link.keepTime) {
|
||||
const range = this.timeSrv.timeRangeForUrl();
|
||||
params['from'] = range.from;
|
||||
params['to'] = range.to;
|
||||
}
|
||||
return {
|
||||
[DataLinkBuiltInVars.valueTime]: {
|
||||
text: valueTime.valueOf(),
|
||||
value: valueTime.valueOf(),
|
||||
},
|
||||
[DataLinkBuiltInVars.seriesName]: {
|
||||
text: seriesQuery,
|
||||
value: seriesQuery,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (link.includeVars) {
|
||||
this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
|
||||
}
|
||||
getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => {
|
||||
const params: KeyValue = {};
|
||||
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
|
||||
|
||||
info.href = this.addParamsToUrl(info.href, params);
|
||||
const info: LinkModel = {
|
||||
href: link.url,
|
||||
title: this.templateSrv.replace(link.title || '', scopedVars),
|
||||
target: link.targetBlank ? '_blank' : '_self',
|
||||
};
|
||||
|
||||
if (link.params) {
|
||||
info.href = this.appendToQueryString(info.href, this.templateSrv.replace(link.params, scopedVars));
|
||||
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 (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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
public/app/plugins/panel/graph/GraphContextMenu.tsx
Normal file
47
public/app/plugins/panel/graph/GraphContextMenu.tsx
Normal file
@@ -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
public/app/plugins/panel/graph/GraphContextMenuCtrl.ts
Normal file
79
public/app/plugins/panel/graph/GraphContextMenuCtrl.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
public/app/plugins/panel/graph/tab_drilldown_links.html
Normal file
5
public/app/plugins/panel/graph/tab_drilldown_links.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<drilldown-links-editor
|
||||
value="ctrl.panel.options.dataLinks"
|
||||
suggestions="ctrl.linkVariableSuggestions"
|
||||
on-change="ctrl.onDataLinksChange"
|
||||
></drilldown-links-editor>
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user