mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PanelChrome: Refactor and refine items next to title (#60514)
Co-authored-by: Torkel Ödegaard <torkel@grafana.com> Co-authored-by: Polina Boneva <13227501+polibb@users.noreply.github.com> Co-authored-by: polinaboneva <polina.boneva@grafana.com>
This commit is contained in:
@@ -44,6 +44,7 @@ export const availableIconsIndex = {
|
|||||||
'check-circle': true,
|
'check-circle': true,
|
||||||
'check-square': true,
|
'check-square': true,
|
||||||
circle: true,
|
circle: true,
|
||||||
|
'circle-mono': true,
|
||||||
'clipboard-alt': true,
|
'clipboard-alt': true,
|
||||||
'clock-nine': true,
|
'clock-nine': true,
|
||||||
cloud: true,
|
cloud: true,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { IconName, IconSize } from '../../types/icon';
|
import { IconName, IconSize } from '../../types/icon';
|
||||||
|
|
||||||
const alwaysMonoIcons: IconName[] = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add', 'library-panel'];
|
const alwaysMonoIcons: IconName[] = [
|
||||||
|
'grafana',
|
||||||
|
'favorite',
|
||||||
|
'heart-break',
|
||||||
|
'heart',
|
||||||
|
'panel-add',
|
||||||
|
'library-panel',
|
||||||
|
'circle-mono',
|
||||||
|
];
|
||||||
|
|
||||||
export function getIconSubDir(name: IconName, type: string): string {
|
export function getIconSubDir(name: IconName, type: string): string {
|
||||||
if (name?.startsWith('gf-')) {
|
if (name?.startsWith('gf-')) {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
|||||||
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
|
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
|
||||||
import { Menu } from '../Menu/Menu';
|
import { Menu } from '../Menu/Menu';
|
||||||
|
|
||||||
import { PanelChromeInfoState } from './PanelChrome';
|
|
||||||
|
|
||||||
const meta: ComponentMeta<typeof PanelChrome> = {
|
const meta: ComponentMeta<typeof PanelChrome> = {
|
||||||
title: 'Visualizations/PanelChrome',
|
title: 'Visualizations/PanelChrome',
|
||||||
component: PanelChrome,
|
component: PanelChrome,
|
||||||
@@ -235,29 +233,11 @@ const ErrorIcon = [
|
|||||||
|
|
||||||
const leftItems = { LoadingIcon, ErrorIcon, Default };
|
const leftItems = { LoadingIcon, ErrorIcon, Default };
|
||||||
|
|
||||||
const titleItems: PanelChromeInfoState[] = [
|
const description =
|
||||||
{
|
'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.';
|
||||||
icon: 'info',
|
|
||||||
tooltip:
|
|
||||||
'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'external-link-alt',
|
|
||||||
tooltip: 'wearegoingonanadventure.openanewtab.maybe',
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'clock-nine',
|
|
||||||
tooltip: 'Time range: 2021-09-01 00:00:00 to 2021-09-01 00:00:00',
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'heart',
|
|
||||||
tooltip: 'Health of the panel',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
Basic.argTypes = {
|
Basic.argTypes = {
|
||||||
|
description: { control: { type: 'text' } },
|
||||||
leftItems: {
|
leftItems: {
|
||||||
options: Object.keys(leftItems),
|
options: Object.keys(leftItems),
|
||||||
mapping: leftItems,
|
mapping: leftItems,
|
||||||
@@ -276,9 +256,8 @@ Basic.args = {
|
|||||||
width: 400,
|
width: 400,
|
||||||
height: 200,
|
height: 200,
|
||||||
title: 'Very long title that should get ellipsis when there is no more space',
|
title: 'Very long title that should get ellipsis when there is no more space',
|
||||||
titleItems,
|
description,
|
||||||
menu,
|
menu,
|
||||||
loadingState: LoadingState.Loading,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -49,13 +49,7 @@ it('renders panel with a header with title in place if prop title', () => {
|
|||||||
|
|
||||||
it('renders panel with a header if prop titleItems', () => {
|
it('renders panel with a header if prop titleItems', () => {
|
||||||
setup({
|
setup({
|
||||||
titleItems: [
|
titleItems: [<div key="title-item-test"> This should be a self-contained node </div>],
|
||||||
{
|
|
||||||
icon: 'info-circle',
|
|
||||||
tooltip: 'This is the panel description',
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('header-container')).toBeInTheDocument();
|
expect(screen.getByTestId('header-container')).toBeInTheDocument();
|
||||||
@@ -63,13 +57,7 @@ it('renders panel with a header if prop titleItems', () => {
|
|||||||
|
|
||||||
it('renders panel with a header with icons in place if prop titleItems', () => {
|
it('renders panel with a header with icons in place if prop titleItems', () => {
|
||||||
setup({
|
setup({
|
||||||
titleItems: [
|
titleItems: [<div key="title-item-test"> This should be a self-contained node </div>],
|
||||||
{
|
|
||||||
icon: 'info-circle',
|
|
||||||
tooltip: 'This is the panel description',
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
|
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import React, { CSSProperties, ReactElement, ReactNode } from 'react';
|
import React, { CSSProperties, ReactNode, ReactElement } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, isIconName, LoadingState } from '@grafana/data';
|
import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../themes';
|
import { useStyles2, useTheme2 } from '../../themes';
|
||||||
import { IconName } from '../../types/icon';
|
|
||||||
import { Dropdown } from '../Dropdown/Dropdown';
|
import { Dropdown } from '../Dropdown/Dropdown';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { IconButton, IconButtonVariant } from '../IconButton/IconButton';
|
|
||||||
import { LoadingBar } from '../LoadingBar/LoadingBar';
|
import { LoadingBar } from '../LoadingBar/LoadingBar';
|
||||||
import { ToolbarButton } from '../ToolbarButton';
|
import { ToolbarButton } from '../ToolbarButton';
|
||||||
import { PopoverContent, Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
|
import { PanelDescription } from './PanelDescription';
|
||||||
import { PanelStatus } from './PanelStatus';
|
import { PanelStatus } from './PanelStatus';
|
||||||
|
|
||||||
interface Status {
|
interface Status {
|
||||||
@@ -20,17 +20,6 @@ interface Status {
|
|||||||
onClick?: (e: React.SyntheticEvent) => void;
|
onClick?: (e: React.SyntheticEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export interface PanelChromeInfoState {
|
|
||||||
icon: IconName;
|
|
||||||
label?: string | ReactNode;
|
|
||||||
tooltip?: PopoverContent;
|
|
||||||
variant?: IconButtonVariant;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -40,7 +29,8 @@ export interface PanelChromeProps {
|
|||||||
children: (innerWidth: number, innerHeight: number) => ReactNode;
|
children: (innerWidth: number, innerHeight: number) => ReactNode;
|
||||||
padding?: PanelPadding;
|
padding?: PanelPadding;
|
||||||
title?: string;
|
title?: string;
|
||||||
titleItems?: PanelChromeInfoState[];
|
description?: string | (() => string);
|
||||||
|
titleItems?: ReactNode[];
|
||||||
menu?: ReactElement | (() => ReactElement);
|
menu?: ReactElement | (() => ReactElement);
|
||||||
/** dragClass, hoverHeader not yet implemented */
|
/** dragClass, hoverHeader not yet implemented */
|
||||||
// dragClass?: string;
|
// dragClass?: string;
|
||||||
@@ -69,6 +59,7 @@ export function PanelChrome({
|
|||||||
children,
|
children,
|
||||||
padding = 'md',
|
padding = 'md',
|
||||||
title = '',
|
title = '',
|
||||||
|
description = '',
|
||||||
titleItems = [],
|
titleItems = [],
|
||||||
menu,
|
menu,
|
||||||
// dragClass,
|
// dragClass,
|
||||||
@@ -82,7 +73,16 @@ export function PanelChrome({
|
|||||||
|
|
||||||
// To Do rely on hoverHeader prop for header, not separate props
|
// To Do rely on hoverHeader prop for header, not separate props
|
||||||
// once hoverHeader is implemented
|
// once hoverHeader is implemented
|
||||||
const hasHeader = title.length > 0 || leftItems.length > 0;
|
//
|
||||||
|
// Backwards compatibility for having a designated space for the header
|
||||||
|
|
||||||
|
const hasHeader =
|
||||||
|
hoverHeader === false &&
|
||||||
|
(title.length > 0 ||
|
||||||
|
titleItems.length > 0 ||
|
||||||
|
description !== '' ||
|
||||||
|
loadingState === LoadingState.Streaming ||
|
||||||
|
leftItems.length > 0);
|
||||||
|
|
||||||
const headerHeight = getHeaderHeight(theme, hasHeader);
|
const headerHeight = getHeaderHeight(theme, hasHeader);
|
||||||
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
|
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
|
||||||
@@ -114,8 +114,10 @@ export function PanelChrome({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} style={containerStyles}>
|
<div className={styles.container} style={containerStyles} aria-label={ariaLabel}>
|
||||||
<div className={styles.loadingBarContainer}>
|
<div className={styles.loadingBarContainer}>
|
||||||
{showLoading ? <LoadingBar width={'28%'} height={'2px'} /> : null}
|
{showLoading ? <LoadingBar width={'28%'} height={'2px'} /> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,29 +129,19 @@ export function PanelChrome({
|
|||||||
</h6>
|
</h6>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStreaming && (
|
<PanelDescription description={description} />
|
||||||
<div className={styles.item} style={itemStyles}>
|
|
||||||
<Tooltip content="Streaming">
|
{titleItems && (
|
||||||
<Icon name="circle" type="mono" size="sm" className={styles.streaming} />
|
<div className={styles.titleItems} data-testid="title-items-container">
|
||||||
</Tooltip>
|
{titleItems.map((item) => item)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{titleItems.length > 0 && (
|
{showStreaming && (
|
||||||
<div className={styles.items} data-testid="title-items-container">
|
<div className={styles.item} style={itemStyles}>
|
||||||
{titleItems
|
<Tooltip content="Streaming">
|
||||||
.filter((item) => isIconName(item.icon))
|
<Icon name="circle-mono" size="sm" className={styles.streaming} />
|
||||||
.map((item, i) => (
|
</Tooltip>
|
||||||
<div key={`${item.icon}-${i}`} className={styles.item} style={itemStyles}>
|
|
||||||
{item.onClick ? (
|
|
||||||
<IconButton tooltip={item.tooltip} name={item.icon} size="sm" onClick={item.onClick} />
|
|
||||||
) : (
|
|
||||||
<Tooltip content={item.tooltip ?? ''}>
|
|
||||||
<Icon name={item.icon} size="sm" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -172,7 +164,6 @@ export function PanelChrome({
|
|||||||
|
|
||||||
{renderStatus()}
|
{renderStatus()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content} style={contentStyle}>
|
<div className={styles.content} style={contentStyle}>
|
||||||
{children(innerWidth, innerHeight)}
|
{children(innerWidth, innerHeight)}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,5 +290,11 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}),
|
}),
|
||||||
|
titleItems: css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
import { useTheme2 } from '../../themes';
|
||||||
|
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description: string | (() => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelDescription({ description }: Props) {
|
||||||
|
const theme = useTheme2();
|
||||||
|
const styles = getStyles(theme);
|
||||||
|
|
||||||
|
const getDescriptionContent = (): JSX.Element => {
|
||||||
|
// description
|
||||||
|
const panelDescription = typeof description === 'function' ? description() : description;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel-info-content markdown-html">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: panelDescription }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return description !== '' ? (
|
||||||
|
<Tooltip interactive content={getDescriptionContent}>
|
||||||
|
<span className={styles.description}>
|
||||||
|
<Icon name="info-circle" size="lg" aria-label="description" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
description: css({
|
||||||
|
color: `${theme.colors.text.secondary}`,
|
||||||
|
backgroundColor: `${theme.colors.background.primary}`,
|
||||||
|
cursor: 'auto',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: `${theme.shape.borderRadius()}`,
|
||||||
|
padding: `${theme.spacing(0, 1)}`,
|
||||||
|
height: ` ${theme.spacing(theme.components.height.md)}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
'&:focus, &:focus-visible': {
|
||||||
|
...getFocusStyles(theme),
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
|
||||||
|
|
||||||
|
'&:hover ': {
|
||||||
|
boxShadow: `${theme.shadows.z1}`,
|
||||||
|
color: `${theme.colors.text.primary}`,
|
||||||
|
background: `${theme.colors.background.secondary}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
code: {
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
},
|
||||||
|
|
||||||
|
'pre > code': {
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -38,6 +38,8 @@ export {
|
|||||||
type ErrorIndicatorProps as PanelChromeErrorIndicatorProps,
|
type ErrorIndicatorProps as PanelChromeErrorIndicatorProps,
|
||||||
} from './ErrorIndicator';
|
} from './ErrorIndicator';
|
||||||
|
|
||||||
|
export { PanelDescription } from './PanelDescription';
|
||||||
|
|
||||||
export { usePanelContext, PanelContextProvider, type PanelContext, PanelContextRoot } from './PanelContext';
|
export { usePanelContext, PanelContextProvider, type PanelContext, PanelContextRoot } from './PanelContext';
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme
|
|||||||
<>
|
<>
|
||||||
{React.cloneElement(children, {
|
{React.cloneElement(children, {
|
||||||
ref: setTriggerRef,
|
ref: setTriggerRef,
|
||||||
|
tabIndex: 0, // tooltip should be keyboard focusable
|
||||||
})}
|
})}
|
||||||
{visible && (
|
{visible && (
|
||||||
<Portal>
|
<Portal>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
import { QueryResultMetaNotice } from '@grafana/data';
|
import { GrafanaTheme2, QueryResultMetaNotice } from '@grafana/data';
|
||||||
import { Icon, Tooltip } from '@grafana/ui';
|
import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
notice: QueryResultMetaNotice;
|
notice: QueryResultMetaNotice;
|
||||||
@@ -9,20 +11,67 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PanelHeaderNotice: FC<Props> = ({ notice, onClick }) => {
|
export const PanelHeaderNotice: FC<Props> = ({ notice, onClick }) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const iconName =
|
const iconName =
|
||||||
notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle';
|
notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle';
|
||||||
|
|
||||||
|
if (notice.inspect && onClick) {
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
className={styles.notice}
|
||||||
|
icon={iconName}
|
||||||
|
key={notice.severity}
|
||||||
|
tooltip={notice.text}
|
||||||
|
onClick={(e) => onClick(e, notice.inspect!)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notice.link) {
|
||||||
|
return (
|
||||||
|
<a className={styles.notice} aria-label={notice.text} href={notice.link} target="_blank" rel="noreferrer">
|
||||||
|
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={notice.text} key={notice.severity}>
|
<Tooltip key={notice.severity} content={notice.text}>
|
||||||
{notice.inspect ? (
|
<span className={styles.iconTooltip}>
|
||||||
<div className="panel-info-notice pointer" onClick={(e) => onClick(e, notice.inspect!)}>
|
<Icon name={iconName} size="lg" />
|
||||||
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
</span>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
|
|
||||||
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
notice: css({
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme.shape.borderRadius(),
|
||||||
|
}),
|
||||||
|
iconTooltip: css({
|
||||||
|
color: `${theme.colors.text.secondary}`,
|
||||||
|
backgroundColor: `${theme.colors.background.primary}`,
|
||||||
|
cursor: 'auto',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: `${theme.shape.borderRadius()}`,
|
||||||
|
padding: `${theme.spacing(0, 1)}`,
|
||||||
|
height: ` ${theme.spacing(theme.components.height.md)}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
'&:focus, &:focus-visible': {
|
||||||
|
...getFocusStyles(theme),
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
|
||||||
|
|
||||||
|
'&:hover ': {
|
||||||
|
boxShadow: `${theme.shadows.z1}`,
|
||||||
|
color: `${theme.colors.text.primary}`,
|
||||||
|
background: `${theme.colors.background.secondary}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { PanelData, GrafanaTheme2, PanelModel, LinkModel, AlertState, DataLink } from '@grafana/data';
|
||||||
|
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
|
||||||
|
|
||||||
|
import { PanelLinks } from '../PanelLinks';
|
||||||
|
|
||||||
|
import { PanelHeaderNotices } from './PanelHeaderNotices';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
alertState?: string;
|
||||||
|
data: PanelData;
|
||||||
|
panelId: number;
|
||||||
|
onShowPanelLinks?: () => Array<LinkModel<PanelModel>>;
|
||||||
|
panelLinks?: DataLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelHeaderTitleItems(props: Props) {
|
||||||
|
const { alertState, data, panelId, onShowPanelLinks, panelLinks } = props;
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
// panel health
|
||||||
|
const alertStateItem = (
|
||||||
|
<Tooltip content={`alerting is ${alertState}`}>
|
||||||
|
<span
|
||||||
|
className={cx(styles.item, {
|
||||||
|
[styles.ok]: alertState === AlertState.OK,
|
||||||
|
[styles.pending]: alertState === AlertState.Pending,
|
||||||
|
[styles.alerting]: alertState === AlertState.Alerting,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon name={alertState === 'alerting' ? 'heart-break' : 'heart'} className="panel-alert-icon" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeshift = (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
content={data.request?.range ? `Time Range: ${data.request.range.from} to ${data.request.range.to}` : ''}
|
||||||
|
>
|
||||||
|
<span className={cx(styles.item, styles.timeshift)}>
|
||||||
|
<Icon name="clock-nine" />
|
||||||
|
{data.request?.timeInfo}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{panelLinks && panelLinks.length > 0 && onShowPanelLinks && (
|
||||||
|
<PanelLinks onShowPanelLinks={onShowPanelLinks} panelLinks={panelLinks} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{<PanelHeaderNotices panelId={panelId} frames={data.series} />}
|
||||||
|
{data.request && data.request.timeInfo && timeshift}
|
||||||
|
{alertState && alertStateItem}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
item: css({
|
||||||
|
label: 'panel-header-item',
|
||||||
|
backgroundColor: `${theme.colors.background.primary}`,
|
||||||
|
cursor: 'auto',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: `${theme.shape.borderRadius()}`,
|
||||||
|
padding: `${theme.spacing(0, 1)}`,
|
||||||
|
height: `${theme.spacing(theme.components.height.md)}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
'&:focus, &:focus-visible': {
|
||||||
|
...getFocusStyles(theme),
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
|
||||||
|
|
||||||
|
'&:hover ': {
|
||||||
|
boxShadow: `${theme.shadows.z1}`,
|
||||||
|
background: `${theme.colors.background.secondary}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ok: css({
|
||||||
|
color: theme.colors.success.text,
|
||||||
|
}),
|
||||||
|
pending: css({
|
||||||
|
color: theme.colors.warning.text,
|
||||||
|
}),
|
||||||
|
alerting: css({
|
||||||
|
color: theme.colors.error.text,
|
||||||
|
}),
|
||||||
|
timeshift: css({
|
||||||
|
color: theme.colors.text.link,
|
||||||
|
|
||||||
|
'&:hover': {
|
||||||
|
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
77
public/app/features/dashboard/dashgrid/PanelLinks.tsx
Normal file
77
public/app/features/dashboard/dashgrid/PanelLinks.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { DataLink, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||||
|
import { Dropdown, Icon, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||||
|
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
panelLinks: DataLink[];
|
||||||
|
onShowPanelLinks: () => LinkModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const getLinksContent = (): JSX.Element => {
|
||||||
|
const interpolatedLinks = onShowPanelLinks();
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
{interpolatedLinks?.map((link, idx) => {
|
||||||
|
return <Menu.Item key={idx} label={link.title} url={link.href} target={link.target} />;
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (panelLinks.length === 1) {
|
||||||
|
const linkModel = onShowPanelLinks()[0];
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={linkModel.href}
|
||||||
|
onClick={linkModel.onClick}
|
||||||
|
target={linkModel.target}
|
||||||
|
title={linkModel.title}
|
||||||
|
className={styles.singleLink}
|
||||||
|
>
|
||||||
|
<Icon name="external-link-alt" size="lg" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Dropdown overlay={getLinksContent}>
|
||||||
|
<ToolbarButton icon="external-link-alt" aria-label="panel links" className={styles.menuTrigger} />
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
menuTrigger: css({
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: `${theme.shape.borderRadius()}`,
|
||||||
|
cursor: 'context-menu',
|
||||||
|
}),
|
||||||
|
singleLink: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
padding: `${theme.spacing(0, 1)}`,
|
||||||
|
height: ` ${theme.spacing(theme.components.height.md)}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
'&:focus, &:focus-visible': {
|
||||||
|
...getFocusStyles(theme),
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
'&: focus:not(:focus-visible)': getMouseFocusStyles(theme),
|
||||||
|
|
||||||
|
'&:hover ': {
|
||||||
|
boxShadow: `${theme.shadows.z1}`,
|
||||||
|
color: `${theme.colors.text.primary}`,
|
||||||
|
background: `${theme.colors.background.secondary}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,17 +11,19 @@ import {
|
|||||||
EventFilterOptions,
|
EventFilterOptions,
|
||||||
FieldConfigSource,
|
FieldConfigSource,
|
||||||
getDefaultTimeRange,
|
getDefaultTimeRange,
|
||||||
|
LinkModel,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PanelData,
|
PanelData,
|
||||||
PanelPlugin,
|
PanelPlugin,
|
||||||
PanelPluginMeta,
|
PanelPluginMeta,
|
||||||
PluginContextProvider,
|
PluginContextProvider,
|
||||||
|
renderMarkdown,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
toDataFrameDTO,
|
toDataFrameDTO,
|
||||||
toUtc,
|
toUtc,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
import { getTemplateSrv, config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||||
import { VizLegendOptions } from '@grafana/schema';
|
import { VizLegendOptions } from '@grafana/schema';
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -35,6 +37,7 @@ import { PANEL_BORDER } from 'app/core/constants';
|
|||||||
import { profiler } from 'app/core/profiler';
|
import { profiler } from 'app/core/profiler';
|
||||||
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
|
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||||
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
|
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
|
||||||
import { RenderEvent } from 'app/types/events';
|
import { RenderEvent } from 'app/types/events';
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ import { loadSnapshotData } from '../utils/loadSnapshotData';
|
|||||||
|
|
||||||
import { PanelHeader } from './PanelHeader/PanelHeader';
|
import { PanelHeader } from './PanelHeader/PanelHeader';
|
||||||
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
|
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
|
||||||
|
import { PanelHeaderTitleItems } from './PanelHeader/PanelHeaderTitleItems';
|
||||||
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
|
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
|
||||||
import { liveTimer } from './liveTimer';
|
import { liveTimer } from './liveTimer';
|
||||||
|
|
||||||
@@ -567,6 +571,27 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
return !panel.hasTitle();
|
return !panel.hasTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onShowPanelDescription = () => {
|
||||||
|
const { panel } = this.props;
|
||||||
|
const descriptionMarkdown = getTemplateSrv().replace(panel.description, panel.scopedVars);
|
||||||
|
const interpolatedDescription = renderMarkdown(descriptionMarkdown);
|
||||||
|
return interpolatedDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
onShowPanelLinks = (): LinkModel[] => {
|
||||||
|
const { panel } = this.props;
|
||||||
|
const linkSupplier = getPanelLinksSupplier(panel);
|
||||||
|
if (linkSupplier) {
|
||||||
|
const panelLinks = linkSupplier && linkSupplier.getLinks(panel.replaceVariables);
|
||||||
|
return panelLinks;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
onOpenInspector = (e: React.SyntheticEvent, tab: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
|
||||||
|
};
|
||||||
onOpenErrorInspect(e: React.SyntheticEvent, tab: string) {
|
onOpenErrorInspect(e: React.SyntheticEvent, tab: string) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
|
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
|
||||||
@@ -590,6 +615,17 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
const title = panel.getDisplayTitle();
|
const title = panel.getDisplayTitle();
|
||||||
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
|
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
|
||||||
|
|
||||||
|
const titleItems = [
|
||||||
|
<PanelHeaderTitleItems
|
||||||
|
key="title-items"
|
||||||
|
alertState={alertState}
|
||||||
|
data={data}
|
||||||
|
panelId={panel.id}
|
||||||
|
panelLinks={panel.links}
|
||||||
|
onShowPanelLinks={this.onShowPanelLinks}
|
||||||
|
/>,
|
||||||
|
];
|
||||||
|
|
||||||
let menu;
|
let menu;
|
||||||
if (!dashboard.meta.publicDashboardAccessToken) {
|
if (!dashboard.meta.publicDashboardAccessToken) {
|
||||||
menu = (
|
menu = (
|
||||||
@@ -610,14 +646,16 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
<PanelChrome
|
<PanelChrome
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
padding={padding}
|
|
||||||
title={title}
|
title={title}
|
||||||
menu={menu}
|
|
||||||
loadingState={data.state}
|
loadingState={data.state}
|
||||||
status={{
|
status={{
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
onClick: (e: React.SyntheticEvent) => this.onOpenErrorInspect(e, InspectTab.Error),
|
onClick: (e: React.SyntheticEvent) => this.onOpenErrorInspect(e, InspectTab.Error),
|
||||||
}}
|
}}
|
||||||
|
description={!!panel.description ? this.onShowPanelDescription : undefined}
|
||||||
|
titleItems={titleItems}
|
||||||
|
menu={menu}
|
||||||
|
padding={padding}
|
||||||
>
|
>
|
||||||
{(innerWidth, innerHeight) => (
|
{(innerWidth, innerHeight) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
1
public/img/icons/mono/circle-mono.svg
Normal file
1
public/img/icons/mono/circle-mono.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
After Width: | Height: | Size: 99 B |
@@ -574,4 +574,4 @@
|
|||||||
"option-tooltip": "Cľęäř şęľęčŧįőʼnş"
|
"option-tooltip": "Cľęäř şęľęčŧįőʼnş"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user