mirror of
https://github.com/grafana/grafana.git
synced 2025-01-02 12:17:01 -06:00
TextPanel: Fixes so panel title is updated when variables change (#30884)
* TextPanel: Fixes so panel title is updated when variables change * Tests: fixes tests * Chore: updates after PR comments
This commit is contained in:
parent
238add18ab
commit
f42bb84cbf
7
packages/grafana-data/src/types/geometry.ts
Normal file
7
packages/grafana-data/src/types/geometry.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* A coordinate on a two dimensional plane.
|
||||||
|
*/
|
||||||
|
export interface CartesianCoords2D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
@ -29,5 +29,6 @@ export * from './explore';
|
|||||||
export * from './legacyEvents';
|
export * from './legacyEvents';
|
||||||
export * from './live';
|
export * from './live';
|
||||||
export * from './variables';
|
export * from './variables';
|
||||||
|
export * from './geometry';
|
||||||
|
|
||||||
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
|
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Global, css as cssCore } from '@emotion/core';
|
import { css as cssCore, Global } from '@emotion/core';
|
||||||
|
import { CartesianCoords2D } from '@grafana/data';
|
||||||
|
|
||||||
import { PlotPluginProps } from '../types';
|
import { PlotPluginProps } from '../types';
|
||||||
import { usePlotPluginContext } from '../context';
|
import { usePlotPluginContext } from '../context';
|
||||||
@ -10,9 +11,9 @@ interface ClickPluginAPI {
|
|||||||
point: { seriesIdx: number | null; dataIdx: number | null };
|
point: { seriesIdx: number | null; dataIdx: number | null };
|
||||||
coords: {
|
coords: {
|
||||||
// coords relative to plot canvas, css px
|
// coords relative to plot canvas, css px
|
||||||
plotCanvas: Coords;
|
plotCanvas: CartesianCoords2D;
|
||||||
// coords relative to viewport , css px
|
// coords relative to viewport , css px
|
||||||
viewport: Coords;
|
viewport: CartesianCoords2D;
|
||||||
};
|
};
|
||||||
// coords relative to plot canvas, css px
|
// coords relative to plot canvas, css px
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
@ -26,11 +27,6 @@ interface ClickPluginProps extends PlotPluginProps {
|
|||||||
children: (api: ClickPluginAPI) => React.ReactElement | null;
|
children: (api: ClickPluginAPI) => React.ReactElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Coords {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exposes API for Graph click interactions
|
// Exposes API for Graph click interactions
|
||||||
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
|
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
|
||||||
const pluginId = `ClickPlugin:${id}`;
|
const pluginId = `ClickPlugin:${id}`;
|
||||||
|
@ -10,10 +10,9 @@ import { PanelChromeAngular } from './PanelChromeAngular';
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
import { initDashboardPanel } from '../state/actions';
|
import { initDashboardPanel } from '../state/actions';
|
||||||
import { updateLocation } from 'app/core/reducers/location';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { PanelModel, DashboardModel } from '../state';
|
import { DashboardModel, PanelModel } from '../state';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin } from '@grafana/data';
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = { initDashboardPanel, updateLocation };
|
const mapDispatchToProps = { initDashboardPanel };
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
@ -76,7 +75,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderPanel(plugin: PanelPlugin) {
|
renderPanel(plugin: PanelPlugin) {
|
||||||
const { dashboard, panel, isViewing, isInView, isEditing, updateLocation } = this.props;
|
const { dashboard, panel, isViewing, isInView, isEditing } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
@ -110,7 +109,6 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
|
|||||||
isInView={isInView}
|
isInView={isInView}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
updateLocation={updateLocation}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { ReplaySubject } from 'rxjs';
|
import { ReplaySubject } from 'rxjs';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
import { act, render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data';
|
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data';
|
||||||
|
|
||||||
import { PanelChrome, Props } from './PanelChrome';
|
import { PanelChrome, Props } from './PanelChrome';
|
||||||
import { DashboardModel, PanelModel } from '../state';
|
import { DashboardModel, PanelModel } from '../state';
|
||||||
import { updateLocation } from '../../../core/actions';
|
|
||||||
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
|
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
|
||||||
import { setTimeSrv, TimeSrv } from '../services/TimeSrv';
|
import { setTimeSrv, TimeSrv } from '../services/TimeSrv';
|
||||||
|
|
||||||
@ -16,6 +17,8 @@ jest.mock('app/core/profiler', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function setupTestContext(options: Partial<Props>) {
|
function setupTestContext(options: Partial<Props>) {
|
||||||
|
const mockStore = configureMockStore<any, any>();
|
||||||
|
const store = mockStore({ dashboard: { panels: [] } });
|
||||||
const subject: ReplaySubject<PanelData> = new ReplaySubject<PanelData>();
|
const subject: ReplaySubject<PanelData> = new ReplaySubject<PanelData>();
|
||||||
const panelQueryRunner = ({
|
const panelQueryRunner = ({
|
||||||
getData: () => subject,
|
getData: () => subject,
|
||||||
@ -48,19 +51,22 @@ function setupTestContext(options: Partial<Props>) {
|
|||||||
isInView: false,
|
isInView: false,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
updateLocation: (jest.fn() as unknown) as typeof updateLocation,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = { ...defaults, ...options };
|
const props = { ...defaults, ...options };
|
||||||
const { rerender } = render(<PanelChrome {...props} />);
|
const { rerender } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<PanelChrome {...props} />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
return { rerender, props, subject };
|
return { rerender, props, subject, store };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PanelChrome', () => {
|
describe('PanelChrome', () => {
|
||||||
describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => {
|
describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => {
|
||||||
it('then it should load the panel successfully when scrolled into view again', () => {
|
it('then it should load the panel successfully when scrolled into view again', () => {
|
||||||
const { rerender, props, subject } = setupTestContext({});
|
const { rerender, props, subject, store } = setupTestContext({});
|
||||||
|
|
||||||
expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
@ -70,7 +76,11 @@ describe('PanelChrome', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const newProps = { ...props, isInView: true };
|
const newProps = { ...props, isInView: true };
|
||||||
rerender(<PanelChrome {...newProps} />);
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<PanelChrome {...newProps} />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument();
|
expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -10,7 +10,6 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
|
|||||||
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
||||||
import { profiler } from 'app/core/profiler';
|
import { profiler } from 'app/core/profiler';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { updateLocation } from 'app/core/actions';
|
|
||||||
// Types
|
// Types
|
||||||
import { DashboardModel, PanelModel } from '../state';
|
import { DashboardModel, PanelModel } from '../state';
|
||||||
import { PANEL_BORDER } from 'app/core/constants';
|
import { PANEL_BORDER } from 'app/core/constants';
|
||||||
@ -40,7 +39,6 @@ export interface Props {
|
|||||||
isInView: boolean;
|
isInView: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
updateLocation: typeof updateLocation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
@ -324,7 +322,7 @@ export class PanelChrome extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboard, panel, isViewing, isEditing, width, height, updateLocation } = this.props;
|
const { dashboard, panel, isViewing, isEditing, width, height } = this.props;
|
||||||
const { errorMessage, data } = this.state;
|
const { errorMessage, data } = this.state;
|
||||||
const { transparent } = panel;
|
const { transparent } = panel;
|
||||||
|
|
||||||
@ -347,7 +345,6 @@ export class PanelChrome extends Component<Props, State> {
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
isViewing={isViewing}
|
isViewing={isViewing}
|
||||||
data={data}
|
data={data}
|
||||||
updateLocation={updateLocation}
|
|
||||||
/>
|
/>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{({ error }) => {
|
{({ error }) => {
|
||||||
|
@ -14,7 +14,6 @@ import config from 'app/core/config';
|
|||||||
import { DashboardModel, PanelModel } from '../state';
|
import { DashboardModel, PanelModel } from '../state';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data';
|
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data';
|
||||||
import { updateLocation } from 'app/core/actions';
|
|
||||||
import { PANEL_BORDER } from 'app/core/constants';
|
import { PANEL_BORDER } from 'app/core/constants';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { RenderEvent } from 'app/types/events';
|
import { RenderEvent } from 'app/types/events';
|
||||||
@ -36,7 +35,6 @@ interface ConnectedProps {
|
|||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
setPanelAngularComponent: typeof setPanelAngularComponent;
|
setPanelAngularComponent: typeof setPanelAngularComponent;
|
||||||
updateLocation: typeof updateLocation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Props = OwnProps & ConnectedProps & DispatchProps;
|
export type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||||
@ -214,7 +212,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboard, panel, isViewing, isEditing, plugin, angularComponent, updateLocation } = this.props;
|
const { dashboard, panel, isViewing, isEditing, plugin } = this.props;
|
||||||
const { errorMessage, data, alertState } = this.state;
|
const { errorMessage, data, alertState } = this.state;
|
||||||
const { transparent } = panel;
|
const { transparent } = panel;
|
||||||
|
|
||||||
@ -239,13 +237,11 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
|
|||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
title={panel.title}
|
title={panel.title}
|
||||||
description={panel.description}
|
description={panel.description}
|
||||||
angularComponent={angularComponent}
|
|
||||||
links={panel.links}
|
links={panel.links}
|
||||||
error={errorMessage}
|
error={errorMessage}
|
||||||
isViewing={isViewing}
|
isViewing={isViewing}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
data={data}
|
data={data}
|
||||||
updateLocation={updateLocation}
|
|
||||||
alertState={alertState}
|
alertState={alertState}
|
||||||
/>
|
/>
|
||||||
<div className={panelContentClassNames}>
|
<div className={panelContentClassNames}>
|
||||||
@ -262,6 +258,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent, updateLocation };
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent };
|
||||||
|
|
||||||
export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected);
|
export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected);
|
||||||
|
@ -1,234 +1,74 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { FC } from 'react';
|
||||||
import classNames from 'classnames';
|
import { cx } from 'emotion';
|
||||||
import { DataLink, LoadingState, PanelData, PanelMenuItem, QueryResultMetaNotice } from '@grafana/data';
|
import { DataLink, PanelData } from '@grafana/data';
|
||||||
import { AngularComponent, config } from '@grafana/runtime';
|
import { Icon } from '@grafana/ui';
|
||||||
import { ClickOutsideWrapper, Icon, IconName, Tooltip, stylesFactory } from '@grafana/ui';
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import PanelHeaderCorner from './PanelHeaderCorner';
|
import PanelHeaderCorner from './PanelHeaderCorner';
|
||||||
import { PanelHeaderMenu } from './PanelHeaderMenu';
|
|
||||||
|
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||||
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||||
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
|
import { PanelHeaderNotices } from './PanelHeaderNotices';
|
||||||
import { updateLocation } from 'app/core/actions';
|
import { PanelHeaderMenuTrigger } from './PanelHeaderMenuTrigger';
|
||||||
import { css } from 'emotion';
|
import { PanelHeaderLoadingIndicator } from './PanelHeaderLoadingIndicator';
|
||||||
|
import { PanelHeaderMenuWrapper } from './PanelHeaderMenuWrapper';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
panel: PanelModel;
|
panel: PanelModel;
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
angularComponent?: AngularComponent | null;
|
|
||||||
links?: DataLink[];
|
links?: DataLink[];
|
||||||
error?: string;
|
error?: string;
|
||||||
alertState?: string;
|
alertState?: string;
|
||||||
isViewing: boolean;
|
isViewing: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
updateLocation: typeof updateLocation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClickCoordinates {
|
export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => {
|
||||||
x: number;
|
const onCancelQuery = () => panel.getQueryRunner().cancelQuery();
|
||||||
y: number;
|
const title = panel.replaceVariables(panel.title, {}, 'text');
|
||||||
}
|
const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : '');
|
||||||
|
|
||||||
interface State {
|
return (
|
||||||
panelMenuOpen: boolean;
|
<>
|
||||||
menuItems: PanelMenuItem[];
|
<PanelHeaderLoadingIndicator state={data.state} onClick={onCancelQuery} />
|
||||||
}
|
<div className={className}>
|
||||||
|
<PanelHeaderCorner
|
||||||
export class PanelHeader extends PureComponent<Props, State> {
|
panel={panel}
|
||||||
clickCoordinates: ClickCoordinates = { x: 0, y: 0 };
|
title={panel.title}
|
||||||
|
description={panel.description}
|
||||||
state: State = {
|
scopedVars={panel.scopedVars}
|
||||||
panelMenuOpen: false,
|
links={getPanelLinksSupplier(panel)}
|
||||||
menuItems: [],
|
error={error}
|
||||||
};
|
/>
|
||||||
|
<PanelHeaderMenuTrigger aria-label={selectors.components.Panels.Panel.title(title)}>
|
||||||
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
|
{({ closeMenu, panelMenuOpen }) => {
|
||||||
return {
|
return (
|
||||||
x: Math.floor(event.clientX),
|
<div className="panel-title">
|
||||||
y: Math.floor(event.clientY),
|
<PanelHeaderNotices frames={data.series} panelId={panel.id} />
|
||||||
};
|
{alertState ? (
|
||||||
};
|
<Icon
|
||||||
|
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
|
||||||
onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
className="icon-gf panel-alert-icon"
|
||||||
this.clickCoordinates = this.eventToClickCoordinates(event);
|
style={{ marginRight: '4px' }}
|
||||||
};
|
size="sm"
|
||||||
|
/>
|
||||||
isClick = (clickCoordinates: ClickCoordinates) => {
|
) : null}
|
||||||
return clickCoordinates.x === this.clickCoordinates.x && clickCoordinates.y === this.clickCoordinates.y;
|
<span className="panel-title-text">{title}</span>
|
||||||
};
|
<Icon name="angle-down" className="panel-menu-toggle" />
|
||||||
|
<PanelHeaderMenuWrapper panel={panel} dashboard={dashboard} show={panelMenuOpen} onClose={closeMenu} />
|
||||||
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
|
{data.request && data.request.timeInfo && (
|
||||||
if (!this.isClick(this.eventToClickCoordinates(event))) {
|
<span className="panel-time-info">
|
||||||
return;
|
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
|
||||||
}
|
</span>
|
||||||
|
)}
|
||||||
event.stopPropagation();
|
</div>
|
||||||
|
);
|
||||||
const { dashboard, panel, angularComponent } = this.props;
|
}}
|
||||||
const menuItems = getPanelMenu(dashboard, panel, angularComponent);
|
</PanelHeaderMenuTrigger>
|
||||||
|
</div>
|
||||||
this.setState({
|
</>
|
||||||
panelMenuOpen: !this.state.panelMenuOpen,
|
);
|
||||||
menuItems,
|
};
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
closeMenu = () => {
|
|
||||||
this.setState({
|
|
||||||
panelMenuOpen: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onCancelQuery = () => {
|
|
||||||
this.props.panel.getQueryRunner().cancelQuery();
|
|
||||||
};
|
|
||||||
|
|
||||||
renderLoadingState(state: LoadingState): JSX.Element | null {
|
|
||||||
if (state === LoadingState.Loading) {
|
|
||||||
return (
|
|
||||||
<div className="panel-loading" onClick={this.onCancelQuery}>
|
|
||||||
<Tooltip content="Cancel query">
|
|
||||||
<Icon className="panel-loading__spinner spin-clockwise" name="sync" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === LoadingState.Streaming) {
|
|
||||||
const styles = getStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel-loading" onClick={this.onCancelQuery}>
|
|
||||||
<div title="Streaming (click to stop)" className={styles.streamIndicator} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
openInspect = (e: React.SyntheticEvent, tab: string) => {
|
|
||||||
const { updateLocation, panel } = this.props;
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
updateLocation({
|
|
||||||
query: { inspect: panel.id, inspectTab: tab },
|
|
||||||
partial: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// This will show one icon for each severity
|
|
||||||
renderNotice = (notice: QueryResultMetaNotice) => {
|
|
||||||
let iconName: IconName = 'info-circle';
|
|
||||||
if (notice.severity === 'error' || notice.severity === 'warning') {
|
|
||||||
iconName = 'exclamation-triangle';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip content={notice.text} key={notice.severity}>
|
|
||||||
{notice.inspect ? (
|
|
||||||
<div className="panel-info-notice pointer" onClick={(e) => this.openInspect(e, notice.inspect!)}>
|
|
||||||
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
|
|
||||||
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { panel, error, isViewing, isEditing, data, alertState } = this.props;
|
|
||||||
const { menuItems } = this.state;
|
|
||||||
const title = panel.replaceVariables(panel.title, {}, 'text');
|
|
||||||
|
|
||||||
const panelHeaderClass = classNames({
|
|
||||||
'panel-header': true,
|
|
||||||
'grid-drag-handle': !(isViewing || isEditing),
|
|
||||||
});
|
|
||||||
|
|
||||||
// dedupe on severity
|
|
||||||
const notices: Record<string, QueryResultMetaNotice> = {};
|
|
||||||
|
|
||||||
for (const series of data.series) {
|
|
||||||
if (series.meta && series.meta.notices) {
|
|
||||||
for (const notice of series.meta.notices) {
|
|
||||||
notices[notice.severity] = notice;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{this.renderLoadingState(data.state)}
|
|
||||||
<div className={panelHeaderClass}>
|
|
||||||
<PanelHeaderCorner
|
|
||||||
panel={panel}
|
|
||||||
title={panel.title}
|
|
||||||
description={panel.description}
|
|
||||||
scopedVars={panel.scopedVars}
|
|
||||||
links={getPanelLinksSupplier(panel)}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="panel-title-container"
|
|
||||||
onClick={this.onMenuToggle}
|
|
||||||
onMouseDown={this.onMouseDown}
|
|
||||||
aria-label={selectors.components.Panels.Panel.title(title)}
|
|
||||||
>
|
|
||||||
<div className="panel-title">
|
|
||||||
{Object.values(notices).map(this.renderNotice)}
|
|
||||||
{alertState && (
|
|
||||||
<Icon
|
|
||||||
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
|
|
||||||
className="icon-gf panel-alert-icon"
|
|
||||||
style={{ marginRight: '4px' }}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="panel-title-text">{title}</span>
|
|
||||||
<Icon name="angle-down" className="panel-menu-toggle" />
|
|
||||||
{this.state.panelMenuOpen && (
|
|
||||||
<ClickOutsideWrapper onClick={this.closeMenu} parent={document}>
|
|
||||||
<PanelHeaderMenu items={menuItems} />
|
|
||||||
</ClickOutsideWrapper>
|
|
||||||
)}
|
|
||||||
{data.request && data.request.timeInfo && (
|
|
||||||
<span className="panel-time-info">
|
|
||||||
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Styles
|
|
||||||
*/
|
|
||||||
export const getStyles = stylesFactory(() => {
|
|
||||||
return {
|
|
||||||
streamIndicator: css`
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: ${config.theme.colors.textFaint};
|
|
||||||
box-shadow: 0 0 2px ${config.theme.colors.textFaint};
|
|
||||||
border-radius: 50%;
|
|
||||||
position: relative;
|
|
||||||
top: 6px;
|
|
||||||
right: 1px;
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { GrafanaTheme, LoadingState } from '@grafana/data';
|
||||||
|
import { Icon, Tooltip, useStyles } from '@grafana/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: LoadingState;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderLoadingIndicator: FC<Props> = ({ state, onClick }) => {
|
||||||
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
|
if (state === LoadingState.Loading) {
|
||||||
|
return (
|
||||||
|
<div className="panel-loading" onClick={onClick}>
|
||||||
|
<Tooltip content="Cancel query">
|
||||||
|
<Icon className="panel-loading__spinner spin-clockwise" name="sync" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === LoadingState.Streaming) {
|
||||||
|
return (
|
||||||
|
<div className="panel-loading" onClick={onClick}>
|
||||||
|
<div title="Streaming (click to stop)" className={styles.streamIndicator} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme) {
|
||||||
|
return {
|
||||||
|
streamIndicator: css`
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: ${theme.colors.textFaint};
|
||||||
|
box-shadow: 0 0 2px ${theme.colors.textFaint};
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
top: 6px;
|
||||||
|
right: 1px;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { PanelMenuItem } from '@grafana/data';
|
||||||
|
|
||||||
|
import { DashboardModel, PanelModel } from '../../state';
|
||||||
|
import { StoreState } from '../../../../types';
|
||||||
|
import { getPanelMenu } from '../../utils/getPanelMenu';
|
||||||
|
|
||||||
|
interface PanelHeaderMenuProviderApi {
|
||||||
|
items: PanelMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
panel: PanelModel;
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
children: (props: PanelHeaderMenuProviderApi) => ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderMenuProvider: FC<Props> = ({ panel, dashboard, children }) => {
|
||||||
|
const [items, setItems] = useState<PanelMenuItem[]>([]);
|
||||||
|
const angularComponent = useSelector(
|
||||||
|
(state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(getPanelMenu(dashboard, panel, angularComponent));
|
||||||
|
}, [dashboard, panel, angularComponent, setItems]);
|
||||||
|
|
||||||
|
return children({ items });
|
||||||
|
};
|
@ -0,0 +1,51 @@
|
|||||||
|
import React, { FC, HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react';
|
||||||
|
import { CartesianCoords2D } from '@grafana/data';
|
||||||
|
|
||||||
|
interface PanelHeaderMenuTriggerApi {
|
||||||
|
panelMenuOpen: boolean;
|
||||||
|
closeMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: (props: PanelHeaderMenuTriggerApi) => ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderMenuTrigger: FC<Props> = ({ children, ...divProps }) => {
|
||||||
|
const [clickCoordinates, setClickCoordinates] = useState<CartesianCoords2D>({ x: 0, y: 0 });
|
||||||
|
const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false);
|
||||||
|
const onMenuToggle = useCallback(
|
||||||
|
(event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isClick(clickCoordinates, eventToClickCoordinates(event))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
setPanelMenuOpen(!panelMenuOpen);
|
||||||
|
},
|
||||||
|
[clickCoordinates, panelMenuOpen, setPanelMenuOpen]
|
||||||
|
);
|
||||||
|
const onMouseDown = useCallback(
|
||||||
|
(event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
setClickCoordinates(eventToClickCoordinates(event));
|
||||||
|
},
|
||||||
|
[setClickCoordinates]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...divProps} className="panel-title-container" onClick={onMenuToggle} onMouseDown={onMouseDown}>
|
||||||
|
{children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D): boolean {
|
||||||
|
return clicked.x === current.x && clicked.y === current.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToClickCoordinates(event: MouseEvent<HTMLDivElement>): CartesianCoords2D {
|
||||||
|
return {
|
||||||
|
x: Math.floor(event.clientX),
|
||||||
|
y: Math.floor(event.clientY),
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { ClickOutsideWrapper } from '@grafana/ui';
|
||||||
|
import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider';
|
||||||
|
import { PanelHeaderMenu } from './PanelHeaderMenu';
|
||||||
|
import { DashboardModel, PanelModel } from '../../state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
panel: PanelModel;
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderMenuWrapper: FC<Props> = ({ show, onClose, panel, dashboard }) => {
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClickOutsideWrapper onClick={onClose} parent={document}>
|
||||||
|
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard}>
|
||||||
|
{({ items }) => {
|
||||||
|
return <PanelHeaderMenu items={items} />;
|
||||||
|
}}
|
||||||
|
</PanelHeaderMenuProvider>
|
||||||
|
</ClickOutsideWrapper>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,27 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { QueryResultMetaNotice } from '@grafana/data';
|
||||||
|
import { Icon, Tooltip } from '@grafana/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
notice: QueryResultMetaNotice;
|
||||||
|
onClick: (e: React.SyntheticEvent, tab: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderNotice: FC<Props> = ({ notice, onClick }) => {
|
||||||
|
const iconName =
|
||||||
|
notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={notice.text} key={notice.severity}>
|
||||||
|
{notice.inspect ? (
|
||||||
|
<div className="panel-info-notice pointer" onClick={(e) => onClick(e, notice.inspect!)}>
|
||||||
|
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<a className="panel-info-notice" href={notice.link} target="_blank" rel="noreferrer">
|
||||||
|
<Icon name={iconName} style={{ marginRight: '8px' }} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,47 @@
|
|||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { DataFrame, QueryResultMetaNotice } from '@grafana/data';
|
||||||
|
import { PanelHeaderNotice } from './PanelHeaderNotice';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { updateLocation } from '../../../../core/actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
panelId: number;
|
||||||
|
frames: DataFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelHeaderNotices: FC<Props> = ({ frames, panelId }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const openInspect = useCallback(
|
||||||
|
(e: React.SyntheticEvent, tab: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateLocation({
|
||||||
|
query: { inspect: panelId, inspectTab: tab },
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[panelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// dedupe on severity
|
||||||
|
const notices: Record<string, QueryResultMetaNotice> = {};
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.meta || !frame.meta.notices) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const notice of frame.meta.notices) {
|
||||||
|
notices[notice.severity] = notice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.values(notices).map((notice) => (
|
||||||
|
<PanelHeaderNotice notice={notice} onClick={openInspect} key={notice.severity} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user