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:
Torkel Ödegaard
2019-06-25 11:38:51 +02:00
committed by GitHub
parent cbe057fce8
commit 335cec07a5
48 changed files with 1585 additions and 199 deletions

View File

@@ -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

View File

@@ -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) => {

View 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';

View File

@@ -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';

View 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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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>

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View File

@@ -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

View File

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

View File

@@ -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 }],
]);
}

View File

@@ -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 = {};

View File

@@ -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;
}

View File

@@ -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>';

View File

@@ -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 [],

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 [],

View File

@@ -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>
);
}

View File

@@ -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[]) {

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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) +

View File

@@ -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);

View File

@@ -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');
});
});
});

View File

@@ -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>

View 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} />;
};

View 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;
};
}

View File

@@ -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);
},
};
}

View File

@@ -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 };

View File

@@ -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();
}

View File

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

View File

@@ -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>
`;

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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;