PanelChrome: Use react Panel Header for angular panels. (#21265)

* WIP: Angular panel chrome, this is going to be tricky

* AngularPanelChrome: initial render works

* Options are showing up

* viz options working

* Fixed singlestat background

* AngularPanels: Fixed alert tab

* Removed anuglar loading spinner

* Dashboard: Refactor dashboard reducer & actions

* Dashboard: minor refactoring

* PanelChrome: loading state moved to header

* Subscribe to render events to solve title update issue

* Time info and query errors now works

* PanelHeader: unifying angular and react behavior

* added getPanelMenu test

* Scrollable now works again

* Various fixes

* Making stuff work

* seperate event emitter for angular

* Fixed issue sending updated dimensions to angular panel

* Minor tweaks

* Fixed tests

* Alerting: alert state now works

* Fixed unit tests

* Fixed a few null check errors

* Simplified events handling

* Fixed strict null checks
This commit is contained in:
Torkel Ödegaard
2020-02-09 10:53:34 +01:00
committed by GitHub
parent 2feaad8b0d
commit 34c397002c
37 changed files with 641 additions and 807 deletions

View File

@@ -136,6 +136,7 @@ export interface PanelMenuItem {
iconClassName?: string;
onClick?: (event: React.MouseEvent<any>) => void;
shortcut?: string;
href?: string;
subMenu?: PanelMenuItem[];
}

View File

@@ -1,5 +1,6 @@
import { eventFactory } from './utils';
import { DataQueryError, DataQueryResponseData } from './datasource';
import { AngularPanelMenuItem } from './panel';
/** Payloads */
export interface PanelChangeViewPayload {
@@ -9,13 +10,6 @@ export interface PanelChangeViewPayload {
toggle?: boolean;
}
export interface MenuElement {
text: string;
click: string;
role?: string;
shortcut?: string;
}
/** Events */
export const refresh = eventFactory('refresh');
@@ -24,7 +18,7 @@ export const dataError = eventFactory<DataQueryError>('data-error');
export const dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode');
export const initPanelActions = eventFactory<MenuElement[]>('init-panel-actions');
export const initPanelActions = eventFactory<AngularPanelMenuItem[]>('init-panel-actions');
export const panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed');

View File

@@ -14,7 +14,7 @@ import StateHistory from './StateHistory';
import 'app/features/alerting/AlertTabCtrl';
import { DashboardModel } from '../dashboard/state/DashboardModel';
import { PanelModel } from '../dashboard/state/PanelModel';
import { PanelModel, angularPanelUpdated } from '../dashboard/state/PanelModel';
import { TestRuleResult } from './TestRuleResult';
import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers';
@@ -22,7 +22,6 @@ import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions';
import { CoreEvents } from 'app/types';
interface Props {
angularPanel?: AngularComponent;
dashboard: DashboardModel;
panel: PanelModel;
changePanelEditorTab: typeof changePanelEditorTab;
@@ -42,19 +41,16 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
};
componentDidMount() {
if (this.shouldLoadAlertTab()) {
this.loadAlertTab();
}
this.loadAlertTab();
this.props.panel.events.on(angularPanelUpdated, this.onAngularPanelUpdated);
}
onAngularPanelUpdated = () => {
this.forceUpdate();
};
componentDidUpdate(prevProps: Props) {
if (this.shouldLoadAlertTab()) {
this.loadAlertTab();
}
}
shouldLoadAlertTab() {
return this.props.angularPanel && this.element && !this.component;
this.loadAlertTab();
}
componentWillUnmount() {
@@ -64,9 +60,13 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
}
async loadAlertTab() {
const { angularPanel, panel } = this.props;
const { panel } = this.props;
const scope = angularPanel.getScope();
if (!this.element || !panel.angularPanel || this.component) {
return;
}
const scope = panel.angularPanel.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {

View File

@@ -90,6 +90,7 @@ export class AnnotationsSrv {
this.alertStatesPromise = getBackendSrv().get('/api/alerts/states-for-dashboard', {
dashboardId: options.dashboard.id,
});
return this.alertStatesPromise;
}

View File

@@ -183,6 +183,7 @@ export class DashboardPage extends PureComponent<Props, State> {
try {
this.props.dashboard.render();
} catch (err) {
console.error(err);
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
}
}

View File

@@ -4,7 +4,6 @@ import classNames from 'classnames';
import AutoSizer from 'react-virtualized-auto-sizer';
// Utils & Services
import { getAngularLoader, AngularComponent } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
// Components
@@ -13,6 +12,7 @@ import { DashboardRow } from '../components/DashboardRow';
import { PanelChrome } from './PanelChrome';
import { PanelEditor } from '../panel_editor/PanelEditor';
import { PanelResizer } from './PanelResizer';
import { PanelChromeAngular } from './PanelChromeAngular';
// Types
import { PanelModel, DashboardModel } from '../state';
@@ -29,7 +29,6 @@ export interface Props {
export interface State {
plugin: PanelPlugin;
angularPanel: AngularComponent;
isLazy: boolean;
}
@@ -42,7 +41,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
this.state = {
plugin: null,
angularPanel: null,
isLazy: !props.isInView,
};
@@ -77,16 +75,13 @@ export class DashboardPanel extends PureComponent<Props, State> {
if (!this.state.plugin || this.state.plugin.meta.id !== pluginId) {
const plugin = await importPanelPlugin(pluginId);
// unmount angular panel
this.cleanUpAngularPanel();
if (panel.type !== pluginId) {
panel.changePlugin(plugin);
} else {
panel.pluginLoaded(plugin);
}
this.setState({ plugin, angularPanel: null });
this.setState({ plugin });
}
}
@@ -98,28 +93,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
if (this.state.isLazy && this.props.isInView) {
this.setState({ isLazy: false });
}
if (!this.element || this.state.angularPanel) {
return;
}
const loader = getAngularLoader();
const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
const angularPanel = loader.load(this.element, scopeProps, template);
this.setState({ angularPanel });
}
cleanUpAngularPanel() {
if (this.state.angularPanel) {
this.state.angularPanel.destroy();
this.element = null;
}
}
componentWillUnmount() {
this.cleanUpAngularPanel();
}
onMouseEnter = () => {
@@ -134,10 +107,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props;
const { plugin } = this.state;
if (plugin.angularPanelCtrl) {
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
}
return (
<AutoSizer>
{({ width, height }) => {
@@ -145,6 +114,20 @@ export class DashboardPanel extends PureComponent<Props, State> {
return null;
}
if (plugin.angularPanelCtrl) {
return (
<PanelChromeAngular
plugin={plugin}
panel={panel}
dashboard={dashboard}
isFullscreen={isFullscreen}
isInView={isInView}
width={width}
height={height}
/>
);
}
return (
<PanelChrome
plugin={plugin}
@@ -164,7 +147,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
render() {
const { panel, dashboard, isFullscreen, isEditing } = this.props;
const { plugin, angularPanel, isLazy } = this.state;
const { plugin, isLazy } = this.state;
if (this.isSpecial(panel.type)) {
return this.specialPanels[panel.type]();
@@ -212,7 +195,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
panel={panel}
plugin={plugin}
dashboard={dashboard}
angularPanel={angularPanel}
onPluginTypeChange={this.onPluginTypeChange}
/>
)}

View File

@@ -272,7 +272,7 @@ export class PanelChrome extends PureComponent<Props, State> {
// do not render component until we have first data
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return this.renderLoadingState();
return null;
}
const PanelComponent = plugin.panel;
@@ -290,7 +290,6 @@ export class PanelChrome extends PureComponent<Props, State> {
return (
<>
{loading === LoadingState.Loading && this.renderLoadingState()}
<div className={panelContentClassNames}>
<PanelComponent
id={panel.id}
@@ -311,14 +310,6 @@ export class PanelChrome extends PureComponent<Props, State> {
);
}
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
hasOverlayHeader() {
const { panel } = this.props;
const { errorMessage, data } = this.state;
@@ -360,6 +351,7 @@ export class PanelChrome extends PureComponent<Props, State> {
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading}
/>
<ErrorBoundary>
{({ error, errorInfo }) => {

View File

@@ -0,0 +1,212 @@
// Libraries
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { Unsubscribable } from 'rxjs';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
// Utils & Services
import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { getAngularLoader } from '@grafana/runtime';
// Types
import { DashboardModel, PanelModel } from '../state';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
isFullscreen: boolean;
isInView: boolean;
width: number;
height: number;
}
export interface State {
data: PanelData;
errorMessage?: string;
alertState?: string;
}
interface AngularScopeProps {
panel: PanelModel;
dashboard: DashboardModel;
size: {
height: number;
width: number;
};
}
export class PanelChromeAngular extends PureComponent<Props, State> {
element?: HTMLElement;
timeSrv: TimeSrv = getTimeSrv();
scopeProps?: AngularScopeProps;
querySubscription: Unsubscribable;
constructor(props: Props) {
super(props);
this.state = {
data: {
state: LoadingState.NotStarted,
series: [],
timeRange: DefaultTimeRange,
},
};
}
componentDidMount() {
const { panel } = this.props;
this.loadAngularPanel();
// subscribe to data events
const queryRunner = panel.getQueryRunner();
this.querySubscription = queryRunner.getData(false).subscribe({
next: (data: PanelData) => this.onPanelDataUpdate(data),
});
}
subscribeToRenderEvent() {
// Subscribe to render event, this is as far as I know only needed for changes to title & transparent
// These changes are modified in the model and only way to communicate that change is via this event
// Need to find another solution for this in tthe future (panel title in redux?)
this.props.panel.events.on(PanelEvents.render, this.onPanelRenderEvent);
}
onPanelRenderEvent = (payload?: any) => {
const { alertState } = this.state;
if (payload && payload.alertState) {
this.setState({ alertState: payload.alertState });
} else if (payload && alertState) {
this.setState({ alertState: undefined });
} else {
// only needed for detecting title updates right now fix before 7.0
this.forceUpdate();
}
};
onPanelDataUpdate(data: PanelData) {
let errorMessage: string | null = null;
if (data.state === LoadingState.Error) {
const { error } = data;
if (error) {
if (errorMessage !== error.message) {
errorMessage = error.message;
}
}
}
this.setState({ data, errorMessage });
}
componentWillUnmount() {
this.cleanUpAngularPanel();
if (this.querySubscription) {
this.querySubscription.unsubscribe();
this.querySubscription = null;
}
this.props.panel.events.off(PanelEvents.render, this.onPanelRenderEvent);
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.plugin !== this.props.plugin) {
this.cleanUpAngularPanel();
this.loadAngularPanel();
}
this.loadAngularPanel();
}
loadAngularPanel() {
const { panel, dashboard, height, width } = this.props;
// if we have no element or already have loaded the panel return
if (!this.element || panel.angularPanel) {
this.scopeProps.size.height = height;
this.scopeProps.size.width = width;
return;
}
const loader = getAngularLoader();
const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
this.scopeProps = {
panel: panel,
dashboard: dashboard,
size: { width, height },
};
// compile angular template and get back handle to scope
panel.setAngularPanel(loader.load(this.element, this.scopeProps, template));
// need to to this every time we load an angular as all events are unsubscribed when panel is destroyed
this.subscribeToRenderEvent();
}
cleanUpAngularPanel() {
const { panel } = this.props;
if (panel.angularPanel) {
panel.setAngularPanel(undefined);
}
}
hasOverlayHeader() {
const { panel } = this.props;
const { errorMessage, data } = this.state;
// always show normal header if we have an error message
if (errorMessage) {
return false;
}
// always show normal header if we have time override
if (data.request && data.request.timeInfo) {
return false;
}
return !panel.hasTitle();
}
render() {
const { dashboard, panel, isFullscreen, plugin } = this.props;
const { errorMessage, data, alertState } = this.state;
const { transparent } = panel;
const containerClassNames = classNames({
'panel-container': true,
'panel-container--absolute': true,
'panel-container--transparent': transparent,
'panel-container--no-title': this.hasOverlayHeader(),
'panel-has-alert': panel.alert !== undefined,
[`panel-alert-state--${alertState}`]: alertState !== undefined,
});
const panelContentClassNames = classNames({
'panel-content': true,
'panel-content--no-padding': plugin.noPadding,
});
return (
<div className={containerClassNames}>
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={data.request ? data.request.timeInfo : null}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading}
/>
<div className={panelContentClassNames}>
<div ref={element => (this.element = element)} className="panel-height-helper" />
</div>
</div>
);
}
}

View File

@@ -1,7 +1,7 @@
import React, { Component } from 'react';
import classNames from 'classnames';
import { isEqual } from 'lodash';
import { DataLink, ScopedVars } from '@grafana/data';
import { DataLink, ScopedVars, PanelMenuItem } from '@grafana/data';
import { ClickOutsideWrapper } from '@grafana/ui';
import { e2e } from '@grafana/e2e';
@@ -12,6 +12,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 { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
export interface Props {
panel: PanelModel;
@@ -23,6 +24,7 @@ export interface Props {
links?: DataLink[];
error?: string;
isFullscreen: boolean;
isLoading: boolean;
}
interface ClickCoordinates {
@@ -32,13 +34,15 @@ interface ClickCoordinates {
interface State {
panelMenuOpen: boolean;
menuItems: PanelMenuItem[];
}
export class PanelHeader extends Component<Props, State> {
clickCoordinates: ClickCoordinates = { x: 0, y: 0 };
state = {
state: State = {
panelMenuOpen: false,
clickCoordinates: { x: 0, y: 0 },
menuItems: [],
};
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -57,13 +61,19 @@ export class PanelHeader extends Component<Props, State> {
};
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.isClick(this.eventToClickCoordinates(event))) {
event.stopPropagation();
this.setState(prevState => ({
panelMenuOpen: !prevState.panelMenuOpen,
}));
if (!this.isClick(this.eventToClickCoordinates(event))) {
return;
}
event.stopPropagation();
const { dashboard, panel } = this.props;
const menuItems = getPanelMenu(dashboard, panel);
this.setState({
panelMenuOpen: !this.state.panelMenuOpen,
menuItems,
});
};
closeMenu = () => {
@@ -72,8 +82,17 @@ export class PanelHeader extends Component<Props, State> {
});
};
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
render() {
const { panel, dashboard, timeInfo, scopedVars, error, isFullscreen } = this.props;
const { panel, timeInfo, scopedVars, error, isFullscreen, isLoading } = this.props;
const { menuItems } = this.state;
const title = templateSrv.replaceWithText(panel.title, scopedVars);
const panelHeaderClass = classNames({
@@ -83,6 +102,7 @@ export class PanelHeader extends Component<Props, State> {
return (
<>
{isLoading && this.renderLoadingState()}
<div className={panelHeaderClass}>
<PanelHeaderCorner
panel={panel}
@@ -105,7 +125,7 @@ export class PanelHeader extends Component<Props, State> {
</span>
{this.state.panelMenuOpen && (
<ClickOutsideWrapper onClick={this.closeMenu}>
<PanelHeaderMenu panel={panel} dashboard={dashboard} />
<PanelHeaderMenu items={menuItems} />
</ClickOutsideWrapper>
)}
{timeInfo && (

View File

@@ -1,13 +1,9 @@
import React, { PureComponent } from 'react';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { PanelMenuItem } from '@grafana/data';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
items: PanelMenuItem[];
}
export class PanelHeaderMenu extends PureComponent<Props> {
@@ -33,9 +29,6 @@ export class PanelHeaderMenu extends PureComponent<Props> {
};
render() {
const { dashboard, panel } = this.props;
const menu = getPanelMenu(dashboard, panel);
return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
return <div className="panel-menu-container dropdown open">{this.renderItems(this.props.items)}</div>;
}
}

View File

@@ -13,7 +13,7 @@ export const PanelHeaderMenuItem: FC<Props & PanelMenuItem> = props => {
<li className="divider" />
) : (
<li className={isSubMenu ? 'dropdown-submenu' : null}>
<a onClick={props.onClick}>
<a onClick={props.onClick} href={props.href}>
{props.iconClassName && <i className={props.iconClassName} />}
<span
className="dropdown-item-text"

View File

@@ -0,0 +1,103 @@
// Libraries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
// Types
import { PanelModel, DashboardModel } from '../state';
import { angularPanelUpdated } from '../state/PanelModel';
import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { PanelCtrl } from 'app/plugins/sdk';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
onPluginTypeChange: (newType: PanelPluginMeta) => void;
}
export class AngularPanelOptions extends PureComponent<Props> {
element?: HTMLElement;
angularOptions: AngularComponent;
constructor(props: Props) {
super(props);
}
componentDidMount() {
this.loadAngularOptions();
this.props.panel.events.on(angularPanelUpdated, this.onAngularPanelUpdated);
}
onAngularPanelUpdated = () => {
this.forceUpdate();
};
componentDidUpdate(prevProps: Props) {
if (this.props.plugin !== prevProps.plugin) {
this.cleanUpAngularOptions();
}
this.loadAngularOptions();
}
componentWillUnmount() {
this.cleanUpAngularOptions();
this.props.panel.events.off(angularPanelUpdated, this.onAngularPanelUpdated);
}
cleanUpAngularOptions() {
if (this.angularOptions) {
this.angularOptions.destroy();
this.angularOptions = null;
}
}
loadAngularOptions() {
const { panel } = this.props;
if (!this.element || !panel.angularPanel || this.angularOptions) {
return;
}
const scope = panel.angularPanel.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {
setTimeout(() => {
this.forceUpdate();
});
return;
}
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode();
panelCtrl.onPluginTypeChange = this.props.onPluginTypeChange;
let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template +=
`
<div class="panel-options-group" ng-cloak>` +
(i > 0
? `<div class="panel-options-group__header">
<span class="panel-options-group__title">{{ctrl.editorTabs[${i}].title}}
</span>
</div>`
: '') +
`<div class="panel-options-group__body">
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
</div>
</div>
`;
}
const loader = getAngularLoader();
const scopeProps = { ctrl: panelCtrl };
this.angularOptions = loader.load(this.element, scopeProps, template);
}
render() {
return <div ref={elem => (this.element = elem)} />;
}
}

View File

@@ -4,7 +4,7 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { Tooltip } from '@grafana/ui';
import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { AngularComponent, config } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { e2e } from '@grafana/e2e';
import { QueriesTab } from './QueriesTab';
@@ -22,7 +22,6 @@ interface PanelEditorProps {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onPluginTypeChange: (newType: PanelPluginMeta) => void;
activeTab: PanelEditorTabIds;
tabs: PanelEditorTab[];
@@ -71,15 +70,14 @@ class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
};
renderCurrentTab(activeTab: string) {
const { panel, dashboard, plugin, angularPanel } = this.props;
const { panel, dashboard, plugin } = this.props;
switch (activeTab) {
case 'advanced':
return <GeneralTab panel={panel} />;
case 'queries':
return <QueriesTab panel={panel} dashboard={dashboard} />;
case 'alert':
return <AlertTab angularPanel={angularPanel} dashboard={dashboard} panel={panel} />;
return <AlertTab dashboard={dashboard} panel={panel} />;
case 'visualization':
return (
<VisualizationTab
@@ -87,7 +85,6 @@ class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
dashboard={dashboard}
plugin={plugin}
onPluginTypeChange={this.onPluginTypeChange}
angularPanel={angularPanel}
/>
);
default:

View File

@@ -1,8 +1,8 @@
// Libraries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { AngularComponent } from '@grafana/runtime';
import { connect } from 'react-redux';
import { StoreState } from 'app/types';
import { updateLocation } from 'app/core/actions';
// Components
@@ -10,11 +10,11 @@ import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { VizTypePicker } from './VizTypePicker';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { AngularPanelOptions } from './AngularPanelOptions';
// Types
import { PanelModel, DashboardModel } from '../state';
import { VizPickerSearch } from './VizPickerSearch';
import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
import { PanelCtrl } from 'app/plugins/sdk';
import { Unsubscribable } from 'rxjs';
import { PanelPlugin, PanelPluginMeta, PanelData, LoadingState, DefaultTimeRange } from '@grafana/data';
@@ -22,7 +22,6 @@ interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onPluginTypeChange: (newType: PanelPluginMeta) => void;
updateLocation: typeof updateLocation;
urlOpenVizPicker: boolean;
@@ -63,10 +62,17 @@ export class VisualizationTab extends PureComponent<Props, State> {
};
renderPanelOptions() {
const { plugin, angularPanel } = this.props;
const { plugin, dashboard, panel } = this.props;
if (angularPanel) {
return <div ref={element => (this.element = element)} />;
if (plugin.angularPanelCtrl) {
return (
<AngularPanelOptions
plugin={plugin}
dashboard={dashboard}
panel={panel}
onPluginTypeChange={this.onPluginTypeChange}
/>
);
}
if (plugin.editor) {
@@ -85,82 +91,16 @@ export class VisualizationTab extends PureComponent<Props, State> {
componentDidMount() {
const { panel } = this.props;
const queryRunner = panel.getQueryRunner();
if (this.shouldLoadAngularOptions()) {
this.loadAngularOptions();
}
this.querySubscription = queryRunner.getData().subscribe({
next: (data: PanelData) => this.setState({ data }),
});
}
componentDidUpdate(prevProps: Props) {
if (this.props.plugin !== prevProps.plugin) {
this.cleanUpAngularOptions();
}
if (this.shouldLoadAngularOptions()) {
this.loadAngularOptions();
}
}
shouldLoadAngularOptions() {
return this.props.angularPanel && this.element && !this.angularOptions;
}
loadAngularOptions() {
const { angularPanel } = this.props;
const scope = angularPanel.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {
setTimeout(() => {
this.forceUpdate();
});
return;
}
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode();
panelCtrl.onPluginTypeChange = this.onPluginTypeChange;
let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template +=
`
<div class="panel-options-group" ng-cloak>` +
(i > 0
? `<div class="panel-options-group__header">
<span class="panel-options-group__title">{{ctrl.editorTabs[${i}].title}}
</span>
</div>`
: '') +
`<div class="panel-options-group__body">
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
</div>
</div>
`;
}
const loader = getAngularLoader();
const scopeProps = { ctrl: panelCtrl };
this.angularOptions = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
}
this.cleanUpAngularOptions();
}
cleanUpAngularOptions() {
if (this.angularOptions) {
this.angularOptions.destroy();
this.angularOptions = null;
}
}
clearQuery = () => {
@@ -276,4 +216,4 @@ const mapDispatchToProps = {
updateLocation,
};
export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps);
export default connect(mapStateToProps, mapDispatchToProps)(VisualizationTab);

View File

@@ -1,6 +1,5 @@
import { PanelModel } from './PanelModel';
import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks';
import { PanelEvents } from '@grafana/data';
class TablePanelCtrl {}
@@ -148,18 +147,19 @@ describe('PanelModel', () => {
});
describe('when changing from angular panel', () => {
let tearDownPublished = false;
const angularPanel = {
scope: {},
destroy: jest.fn(),
};
beforeEach(() => {
model.events.on(PanelEvents.panelTeardown, () => {
tearDownPublished = true;
});
model.angularPanel = angularPanel;
model.changePlugin(getPanelPlugin({ id: 'graph' }));
});
it('should teardown / destroy panel so angular panels event subscriptions are removed', () => {
expect(tearDownPublished).toBe(true);
expect(model.events.getEventCount()).toBe(0);
it('should set angularPanel to undefined and call destory', () => {
expect(angularPanel.destroy.mock.calls.length).toBe(1);
expect(model.angularPanel).toBe(undefined);
});
});

View File

@@ -13,6 +13,7 @@ import {
DataTransformerConfig,
ScopedVars,
} from '@grafana/data';
import { AngularComponent } from '@grafana/runtime';
import config from 'app/core/config';
@@ -22,6 +23,7 @@ import { take } from 'rxjs/operators';
export const panelAdded = eventFactory<PanelModel | undefined>('panel-added');
export const panelRemoved = eventFactory<PanelModel | undefined>('panel-removed');
export const angularPanelUpdated = eventFactory('panel-angular-panel-updated');
export interface GridPos {
x: number;
@@ -41,6 +43,7 @@ const notPersistedProperties: { [str: string]: boolean } = {
cachedPluginOptions: true,
plugin: true,
queryRunner: true,
angularPanel: true,
restoreModel: true,
};
@@ -135,6 +138,8 @@ export class PanelModel {
cachedPluginOptions?: any;
legend?: { show: boolean };
plugin?: PanelPlugin;
angularPanel?: AngularComponent;
private queryRunner?: PanelQueryRunner;
constructor(model: any) {
@@ -290,9 +295,8 @@ export class PanelModel {
const oldPluginId = this.type;
const wasAngular = !!this.plugin.angularPanelCtrl;
// for angular panels we must remove all events and let angular panels do some cleanup
if (wasAngular) {
this.destroy();
if (this.angularPanel) {
this.setAngularPanel(undefined);
}
// remove panel type specific options
@@ -380,19 +384,32 @@ export class PanelModel {
}
destroy() {
this.events.emit(PanelEvents.panelTeardown);
this.events.removeAllListeners();
if (this.queryRunner) {
this.queryRunner.destroy();
this.queryRunner = null;
}
if (this.angularPanel) {
this.angularPanel.destroy();
}
}
setTransformations(transformations: DataTransformerConfig[]) {
this.transformations = transformations;
this.getQueryRunner().setTransformations(transformations);
}
setAngularPanel(component: AngularComponent) {
if (this.angularPanel) {
// this will remove all event listeners
this.angularPanel.destroy();
}
this.angularPanel = component;
this.events.emit(angularPanelUpdated);
}
}
function getPluginVersion(plugin: PanelPlugin): string {

View File

@@ -26,13 +26,13 @@ const dashbardSlice = createSlice({
loadDashboardPermissions: (state, action: PayloadAction<DashboardAclDTO[]>) => {
state.permissions = processAclItems(action.payload);
},
dashboardInitFetching: state => {
dashboardInitFetching: (state, action: PayloadAction) => {
state.initPhase = DashboardInitPhase.Fetching;
},
dashboardInitServices: state => {
dashboardInitServices: (state, action: PayloadAction) => {
state.initPhase = DashboardInitPhase.Services;
},
dashboardInitSlow: state => {
dashboardInitSlow: (state, action: PayloadAction) => {
state.isInitSlow = true;
},
dashboardInitCompleted: (state, action: PayloadAction<MutableDashboard>) => {
@@ -41,16 +41,13 @@ const dashbardSlice = createSlice({
state.isInitSlow = false;
},
dashboardInitFailed: (state, action: PayloadAction<DashboardInitError>) => {
const failedDashboard = new DashboardModel(
{ title: 'Dashboard init failed' },
{ canSave: false, canEdit: false }
);
state.initPhase = DashboardInitPhase.Failed;
state.initError = action.payload;
state.getModel = () => failedDashboard;
state.getModel = () => {
return new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false });
};
},
cleanUpDashboard: state => {
cleanUpDashboard: (state, action: PayloadAction) => {
if (state.getModel()) {
state.getModel().destroy();
state.getModel = () => null;
@@ -63,7 +60,7 @@ const dashbardSlice = createSlice({
setDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction<QueriesToUpdateOnDashboardLoad>) => {
state.modifiedQueries = action.payload;
},
clearDashboardQueriesToUpdateOnLoad: state => {
clearDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => {
state.modifiedQueries = null;
},
},

View File

@@ -0,0 +1,63 @@
import { PanelModel, DashboardModel } from '../state';
import { getPanelMenu } from './getPanelMenu';
describe('getPanelMenu', () => {
it('should return the correct panel menu items', () => {
const panel = new PanelModel({});
const dashboard = new DashboardModel({});
const menuItems = getPanelMenu(dashboard, panel);
expect(menuItems).toMatchInlineSnapshot(`
Array [
Object {
"iconClassName": "gicon gicon-viewer",
"onClick": [Function],
"shortcut": "v",
"text": "View",
},
Object {
"iconClassName": "gicon gicon-editor",
"onClick": [Function],
"shortcut": "e",
"text": "Edit",
},
Object {
"iconClassName": "fa fa-fw fa-share",
"onClick": [Function],
"shortcut": "p s",
"text": "Share",
},
Object {
"iconClassName": "fa fa-fw fa-cube",
"onClick": [Function],
"subMenu": Array [
Object {
"onClick": [Function],
"shortcut": "p d",
"text": "Duplicate",
},
Object {
"onClick": [Function],
"text": "Copy",
},
Object {
"onClick": [Function],
"text": "Panel JSON",
},
],
"text": "More...",
"type": "submenu",
},
Object {
"type": "divider",
},
Object {
"iconClassName": "fa fa-fw fa-trash",
"onClick": [Function],
"shortcut": "p r",
"text": "Remove",
},
]
`);
});
});

View File

@@ -3,7 +3,6 @@ import { store } from 'app/store/store';
import config from 'app/core/config';
import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data';
import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
@@ -11,8 +10,9 @@ import { contextSrv } from '../../../core/services/context_srv';
import { navigateToExplore } from '../../explore/state/actions';
import { getExploreUrl } from '../../../core/utils/explore';
import { getTimeSrv } from '../services/TimeSrv';
import { PanelCtrl } from '../../panel/panel_ctrl';
export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
export function getPanelMenu(dashboard: DashboardModel, panel: PanelModel): PanelMenuItem[] {
const onViewPanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
store.dispatch(
@@ -123,7 +123,7 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
shortcut: 'p s',
});
if (contextSrv.hasAccessToExplore() && panel.datasource) {
if (contextSrv.hasAccessToExplore() && !panel.plugin.meta.skipDataQuery) {
menu.push({
text: 'Explore',
iconClassName: 'gicon gicon-explore',
@@ -170,6 +170,29 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
onClick: onEditPanelJson,
});
// add old angular panel options
if (panel.angularPanel) {
const scope = panel.angularPanel.getScope();
const panelCtrl: PanelCtrl = scope.$$childHead.ctrl;
const angularMenuItems = panelCtrl.getExtendedMenu();
for (const item of angularMenuItems) {
const reactItem: PanelMenuItem = {
text: item.text,
href: item.href,
shortcut: item.shortcut,
};
if (item.click) {
reactItem.onClick = () => {
scope.$eval(item.click, { ctrl: panelCtrl });
};
}
subMenu.push(reactItem);
}
}
menu.push({
type: 'submenu',
text: 'More...',
@@ -190,4 +213,4 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
}
return menu;
};
}

View File

@@ -1,4 +1,3 @@
import './panel_header';
import './panel_directive';
import './query_ctrl';
import './panel_editor_tab';

View File

@@ -1,10 +1,6 @@
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import { getExploreUrl } from 'app/core/utils/explore';
import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { ContextSrv } from 'app/core/services/context_srv';
import {
DataFrame,
@@ -164,32 +160,14 @@ class MetricsPanelCtrl extends PanelCtrl {
updateTimeRange(datasource?: DataSourceApi) {
this.datasource = datasource || this.datasource;
this.range = this.timeSrv.timeRange();
this.resolution = getResolution(this.panel);
const newTimeData = applyPanelTimeOverrides(this.panel, this.range);
this.timeInfo = newTimeData.timeInfo;
this.range = newTimeData.timeRange;
this.calculateInterval();
return this.datasource;
}
calculateInterval() {
let intervalOverride = this.panel.interval;
// if no panel interval check datasource
if (intervalOverride) {
intervalOverride = this.templateSrv.replace(intervalOverride, this.panel.scopedVars);
} else if (this.datasource && this.datasource.interval) {
intervalOverride = this.datasource.interval;
}
const res = kbn.calculateInterval(this.range, this.resolution, intervalOverride);
this.interval = res.interval;
this.intervalMs = res.intervalMs;
}
issueQueries(datasource: DataSourceApi) {
this.datasource = datasource;
@@ -206,8 +184,9 @@ class MetricsPanelCtrl extends PanelCtrl {
panelId: panel.id,
dashboardId: this.dashboard.id,
timezone: this.dashboard.timezone,
timeInfo: this.timeInfo,
timeRange: this.range,
widthPixels: this.resolution, // The pixel width
widthPixels: this.width,
maxDataPoints: panel.maxDataPoints,
minInterval: panel.interval,
scopedVars: panel.scopedVars,
@@ -248,25 +227,6 @@ class MetricsPanelCtrl extends PanelCtrl {
this.processDataError(err);
}
}
async getAdditionalMenuItems() {
const items = [];
if (this.contextSrv.hasAccessToExplore() && this.datasource) {
items.push({
text: 'Explore',
icon: 'gicon gicon-explore',
shortcut: 'x',
href: await getExploreUrl({
panel: this.panel,
panelTargets: this.panel.targets,
panelDatasource: this.datasource,
datasourceSrv: this.datasourceSrv,
timeSrv: this.timeSrv,
}),
});
}
return items;
}
}
export { MetricsPanelCtrl };

View File

@@ -1,23 +1,9 @@
import _ from 'lodash';
import { escapeHtml, sanitize } from 'app/core/utils/text';
import config from 'app/core/config';
import { Emitter, profiler } from 'app/core/core';
import getFactors from 'app/core/utils/factors';
import {
calculateInnerPanelHeight,
copyPanel as copyPanelUtil,
duplicatePanel,
editPanelJson as editPanelJsonUtil,
removePanel,
sharePanel as sharePanelUtil,
} from 'app/features/dashboard/utils/panel';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { profiler } from 'app/core/core';
import { Emitter } from 'app/core/utils/emitter';
import { auto } from 'angular';
import { TemplateSrv } from '../templating/template_srv';
import { getPanelLinksSupplier } from './panellinks/linkSuppliers';
import { AppEvent, PanelEvents, PanelPluginMeta, renderMarkdown } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
import { AppEvent, PanelEvents, PanelPluginMeta, AngularPanelMenuItem } from '@grafana/data';
import { DashboardModel } from '../dashboard/state';
export class PanelCtrl {
@@ -32,12 +18,12 @@ export class PanelCtrl {
$location: any;
$timeout: any;
editModeInitiated: boolean;
height: any;
height: number;
width: number;
containerHeight: any;
events: Emitter;
loading: boolean;
timing: any;
maxPanelsPerRowOptions: number[];
constructor($scope: any, $injector: auto.IInjectorService) {
this.$injector = $injector;
@@ -74,31 +60,10 @@ export class PanelCtrl {
this.$scope.$root.appEvent(event, payload);
}
changeView(fullscreen: boolean, edit: boolean) {
this.publishAppEvent(PanelEvents.panelChangeView, {
fullscreen,
edit,
panelId: this.panel.id,
});
}
viewPanel() {
this.changeView(true, false);
}
editPanel() {
this.changeView(true, true);
}
exitFullscreen() {
this.changeView(false, false);
}
initEditMode() {
if (!this.editModeInitiated) {
this.editModeInitiated = true;
this.events.emit(PanelEvents.editModeInitialized);
this.maxPanelsPerRowOptions = getFactors(GRID_COLUMN_COUNT);
}
}
@@ -118,88 +83,8 @@ export class PanelCtrl {
}
}
async getMenu() {
const menu = [];
menu.push({
text: 'View',
click: 'ctrl.viewPanel();',
icon: 'gicon gicon-viewer',
shortcut: 'v',
});
if (this.dashboard.canEditPanel(this.panel)) {
menu.push({
text: 'Edit',
click: 'ctrl.editPanel();',
role: 'Editor',
icon: 'gicon gicon-editor',
shortcut: 'e',
});
}
menu.push({
text: 'Share',
click: 'ctrl.sharePanel();',
icon: 'fa fa-fw fa-share',
shortcut: 'p s',
});
if (config.featureToggles.inspect) {
menu.push({
text: 'Inspect',
icon: 'fa fa-fw fa-info-circle',
click: 'ctrl.inspectPanel();',
shortcut: 'p i',
});
}
// Additional items from sub-class
menu.push(...(await this.getAdditionalMenuItems()));
const extendedMenu = this.getExtendedMenu();
menu.push({
text: 'More ...',
click: '',
icon: 'fa fa-fw fa-cube',
submenu: extendedMenu,
});
if (this.dashboard.canEditPanel(this.panel)) {
menu.push({ divider: true, role: 'Editor' });
menu.push({
text: 'Remove',
click: 'ctrl.removePanel();',
role: 'Editor',
icon: 'fa fa-fw fa-trash',
shortcut: 'p r',
});
}
return menu;
}
getExtendedMenu() {
const menu = [];
if (!this.panel.fullscreen && this.dashboard.canEditPanel(this.panel)) {
menu.push({
text: 'Duplicate',
click: 'ctrl.duplicate()',
role: 'Editor',
shortcut: 'p d',
});
menu.push({
text: 'Copy',
click: 'ctrl.copyPanel()',
role: 'Editor',
});
}
menu.push({
text: 'Panel JSON',
click: 'ctrl.editPanelJson(); dismiss();',
});
const menu: AngularPanelMenuItem[] = [];
this.events.emit(PanelEvents.initPanelActions, menu);
return menu;
}
@@ -213,93 +98,10 @@ export class PanelCtrl {
return this.dashboard.meta.fullscreen && !this.panel.fullscreen;
}
calculatePanelHeight(containerHeight: number) {
this.containerHeight = containerHeight;
this.height = calculateInnerPanelHeight(this.panel, containerHeight);
}
render(payload?: any) {
this.events.emit(PanelEvents.render, payload);
}
duplicate() {
duplicatePanel(this.dashboard, this.panel);
}
removePanel() {
removePanel(this.dashboard, this.panel, true);
}
editPanelJson() {
editPanelJsonUtil(this.dashboard, this.panel);
}
copyPanel() {
copyPanelUtil(this.panel);
}
sharePanel() {
sharePanelUtil(this.dashboard, this.panel);
}
inspectPanel() {
getLocationSrv().update({
query: {
inspect: this.panel.id,
},
partial: true,
});
}
getInfoMode() {
if (this.error) {
return 'error';
}
if (!!this.panel.description) {
return 'info';
}
if (this.panel.links && this.panel.links.length) {
return 'links';
}
return '';
}
getInfoContent(options: { mode: string }) {
const { panel } = this;
let markdown = panel.description || '';
if (options.mode === 'tooltip') {
markdown = this.error || panel.description || '';
}
const templateSrv: TemplateSrv = this.$injector.get('templateSrv');
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
let html = '<div class="markdown-html panel-info-content">';
const md = renderMarkdown(interpolatedMarkdown);
html += md;
if (panel.links && panel.links.length > 0) {
const interpolatedLinks = getPanelLinksSupplier(panel).getLinks();
html += '<ul class="panel-info-corner-links">';
for (const link of interpolatedLinks) {
html +=
'<li><a class="panel-menu-link" href="' +
escapeHtml(link.href) +
'" target="' +
escapeHtml(link.target) +
'">' +
escapeHtml(link.title) +
'</a></li>';
}
html += '</ul>';
}
html += '</div>';
return config.disableSanitizeHtml ? html : sanitize(html);
}
// overriden from react
onPluginTypeChange = (plugin: PanelPluginMeta) => {};
}

View File

@@ -1,35 +1,14 @@
import angular from 'angular';
import $ from 'jquery';
// @ts-ignore
import Drop from 'tether-drop';
// @ts-ignore
import baron from 'baron';
import { PanelEvents } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
import { e2e } from '@grafana/e2e';
import { PanelModel } from '../dashboard/state';
import { PanelCtrl } from './panel_ctrl';
const module = angular.module('grafana.directives');
const panelTemplate = `
<div class="panel-container" ng-class="{'panel-container--no-title': !ctrl.panel.title.length}">
<div class="panel-header" ng-class="{'grid-drag-handle': !ctrl.panel.fullscreen}">
<span class="panel-info-corner">
<i class="fa"></i>
<span class="panel-info-corner-inner"></span>
</span>
<span class="panel-loading" ng-show="ctrl.loading">
<i class="fa fa-spinner fa-spin"></i>
</span>
<panel-header class="panel-title-container" panel-ctrl="ctrl" aria-label={{ctrl.selectors.title(ctrl.panel.title)}}></panel-header>
</div>
<div class="panel-content">
<ng-transclude class="panel-height-helper"></ng-transclude>
</div>
</div>
</div>
<ng-transclude class="panel-height-helper"></ng-transclude>
`;
module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
@@ -39,52 +18,19 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
transclude: true,
scope: { ctrl: '=' },
link: (scope: any, elem) => {
const panelContainer = elem.find('.panel-container');
const panelContent = elem.find('.panel-content');
const cornerInfoElem = elem.find('.panel-info-corner');
const ctrl = scope.ctrl;
ctrl.selectors = e2e.pages.Dashboard.Panels.Panel.selectors;
let infoDrop: any;
const ctrl: PanelCtrl = scope.ctrl;
const panel: PanelModel = scope.ctrl.panel;
let panelScrollbar: any;
// the reason for handling these classes this way is for performance
// limit the watchers on panels etc
let transparentLastState = false;
let lastHasAlertRule = false;
let lastAlertState: boolean;
let hasAlertRule;
function mouseEnter() {
panelContainer.toggleClass('panel-hover-highlight', true);
ctrl.dashboard.setPanelFocus(ctrl.panel.id);
}
function mouseLeave() {
panelContainer.toggleClass('panel-hover-highlight', false);
ctrl.dashboard.setPanelFocus(0);
}
function resizeScrollableContent() {
if (panelScrollbar) {
panelScrollbar.update();
}
}
function infoCornerClicked() {
if (ctrl.error) {
getLocationSrv().update({ partial: true, query: { inspect: ctrl.panel.id } });
}
}
// set initial transparency
if (ctrl.panel.transparent) {
transparentLastState = true;
panelContainer.addClass('panel-container--transparent');
}
// update scrollbar after mounting
ctrl.events.on(PanelEvents.componentDidMount, () => {
if (ctrl.__proto__.constructor.scrollable) {
if ((ctrl as any).__proto__.constructor.scrollable) {
const scrollRootClass = 'baron baron__root baron__clipper panel-content--scrollable';
const scrollerClass = 'baron__scroller';
const scrollBarHTML = `
@@ -93,8 +39,8 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
</div>
`;
const scrollRoot = panelContent;
const scroller = panelContent.find(':first').find(':first');
const scrollRoot = elem;
const scroller = elem.find(':first').find(':first');
scrollRoot.addClass(scrollRootClass);
$(scrollBarHTML).appendTo(scrollRoot);
@@ -112,112 +58,44 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
}
});
ctrl.events.on(PanelEvents.panelSizeChanged, () => {
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
function onPanelSizeChanged() {
$timeout(() => {
resizeScrollableContent();
ctrl.render();
});
});
}
ctrl.events.on(PanelEvents.viewModeChanged, () => {
function onViewModeChanged() {
// first wait one pass for dashboard fullscreen view mode to take effect (classses being applied)
setTimeout(() => {
// then recalc style
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
// then wait another cycle (this might not be needed)
$timeout(() => {
ctrl.render();
resizeScrollableContent();
});
}, 10);
});
ctrl.events.on(PanelEvents.render, () => {
// set initial height
if (!ctrl.height) {
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
}
if (transparentLastState !== ctrl.panel.transparent) {
panelContainer.toggleClass('panel-container--transparent', ctrl.panel.transparent === true);
transparentLastState = ctrl.panel.transparent;
}
hasAlertRule = ctrl.panel.alert !== undefined;
if (lastHasAlertRule !== hasAlertRule) {
panelContainer.toggleClass('panel-has-alert', hasAlertRule);
lastHasAlertRule = hasAlertRule;
}
if (ctrl.alertState) {
if (lastAlertState) {
panelContainer.removeClass('panel-alert-state--' + lastAlertState);
}
if (
ctrl.alertState.state === 'ok' ||
ctrl.alertState.state === 'alerting' ||
ctrl.alertState.state === 'pending'
) {
panelContainer.addClass('panel-alert-state--' + ctrl.alertState.state);
}
lastAlertState = ctrl.alertState.state;
} else if (lastAlertState) {
panelContainer.removeClass('panel-alert-state--' + lastAlertState);
lastAlertState = null;
}
});
function updatePanelCornerInfo() {
const cornerMode = ctrl.getInfoMode();
cornerInfoElem[0].className = 'panel-info-corner panel-info-corner--' + cornerMode;
if (cornerMode) {
if (infoDrop) {
infoDrop.destroy();
}
infoDrop = new Drop({
target: cornerInfoElem[0],
content: () => {
return ctrl.getInfoContent({ mode: 'tooltip' });
},
classes: ctrl.error ? 'drop-error' : 'drop-help',
openOn: 'hover',
hoverOpenDelay: 100,
tetherOptions: {
attachment: 'bottom left',
targetAttachment: 'top left',
constraints: [
{
to: 'window',
attachment: 'together',
pin: true,
},
],
},
});
}
}
scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
function onPanelModelRender(payload?: any) {
ctrl.height = scope.$parent.$parent.size.height;
ctrl.width = scope.$parent.$parent.size.width;
}
elem.on('mouseenter', mouseEnter);
elem.on('mouseleave', mouseLeave);
function onPanelModelRefresh() {
ctrl.height = scope.$parent.$parent.size.height;
ctrl.width = scope.$parent.$parent.size.width;
}
cornerInfoElem.on('click', infoCornerClicked);
panel.events.on(PanelEvents.refresh, onPanelModelRefresh);
panel.events.on(PanelEvents.render, onPanelModelRender);
panel.events.on(PanelEvents.panelSizeChanged, onPanelSizeChanged);
panel.events.on(PanelEvents.viewModeChanged, onViewModeChanged);
scope.$on('$destroy', () => {
elem.off();
cornerInfoElem.off();
if (infoDrop) {
infoDrop.destroy();
}
panel.events.emit(PanelEvents.panelTeardown);
panel.events.removeAllListeners();
if (panelScrollbar) {
panelScrollbar.dispose();
@@ -226,17 +104,3 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
},
};
});
module.directive('panelHelpCorner', $rootScope => {
return {
restrict: 'E',
template: `
<span class="alert-error panel-error small pointer" ng-if="ctrl.error" ng-click="ctrl.openInspector()">
<span data-placement="top" bs-tooltip="ctrl.error">
<i class="fa fa-exclamation"></i><span class="panel-error-arrow"></span>
</span>
</span>
`,
link: (scope, elem) => {},
};
});

View File

@@ -1,123 +0,0 @@
import { coreModule } from 'app/core/core';
import { AngularPanelMenuItem } from '@grafana/data';
import { e2e } from '@grafana/e2e';
const template = `
<span class="panel-title">
<span class="icon-gf panel-alert-icon"></span>
<span class="panel-title-text">{{ctrl.panel.title | interpolateTemplateVars:this}}</span>
<span class="panel-menu-container dropdown">
<span class="fa fa-caret-down panel-menu-toggle" data-toggle="dropdown"></span>
<ul class="dropdown-menu dropdown-menu--menu panel-menu" role="menu">
</ul>
</span>
<span class="panel-time-info" ng-if="ctrl.timeInfo"><i class="fa fa-clock-o"></i> {{ctrl.timeInfo}}</span>
</span>`;
function renderMenuItem(item: AngularPanelMenuItem, ctrl: any) {
let html = '';
let listItemClass = '';
if (item.divider) {
return '<li class="divider"></li>';
}
if (item.submenu) {
listItemClass = 'dropdown-submenu';
}
html += `<li class="${listItemClass}"><a `;
if (item.click) {
html += ` ng-click="${item.click}"`;
}
if (item.href) {
html += ` href="${item.href}"`;
}
html += `><i class="${item.icon}"></i>`;
html += `<span class="dropdown-item-text" aria-label="${e2e.pages.Dashboard.Panels.Panel.selectors.headerItems(
item.text
)}">${item.text}</span>`;
if (item.shortcut) {
html += `<span class="dropdown-menu-item-shortcut">${item.shortcut}</span>`;
}
html += `</a>`;
if (item.submenu) {
html += '<ul class="dropdown-menu dropdown-menu--menu panel-menu">';
for (const subitem of item.submenu) {
html += renderMenuItem(subitem, ctrl);
}
html += '</ul>';
}
html += `</li>`;
return html;
}
async function createMenuTemplate(ctrl: any) {
let html = '';
for (const item of await ctrl.getMenu()) {
html += renderMenuItem(item, ctrl);
}
return html;
}
/** @ngInject */
function panelHeader($compile: any) {
return {
restrict: 'E',
template: template,
link: (scope: any, elem: any, attrs: any) => {
const menuElem = elem.find('.panel-menu');
let menuScope: any;
let isDragged: boolean;
elem.click(async (evt: any) => {
const targetClass = evt.target.className;
// remove existing scope
if (menuScope) {
menuScope.$destroy();
}
menuScope = scope.$new();
const menuHtml = await createMenuTemplate(scope.ctrl);
menuElem.html(menuHtml);
$compile(menuElem)(menuScope);
if (targetClass.indexOf('panel-title-text') >= 0 || targetClass.indexOf('panel-title') >= 0) {
togglePanelMenu(evt);
}
});
function togglePanelMenu(e: any) {
if (!isDragged) {
e.stopPropagation();
elem.find('[data-toggle=dropdown]').dropdown('toggle');
}
}
let mouseX: number, mouseY: number;
elem.mousedown((e: any) => {
mouseX = e.pageX;
mouseY = e.pageY;
});
elem.mouseup((e: any) => {
if (mouseX === e.pageX && mouseY === e.pageY) {
isDragged = false;
} else {
isDragged = true;
}
});
},
};
}
coreModule.directive('panelHeader', panelHeader);

View File

@@ -22,31 +22,10 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { MetricsPanelCtrl } from '../metrics_panel_ctrl';
describe('MetricsPanelCtrl', () => {
describe('when getting additional menu items', () => {
describe('and has no datasource set but user has access to explore', () => {
it('should not return any items', async () => {
const ctrl = setupController({ hasAccessToExplore: true });
expect((await ctrl.getAdditionalMenuItems()).length).toBe(0);
});
});
describe('and has datasource set that supports explore and user does not have access to explore', () => {
it('should not return any items', async () => {
const ctrl = setupController({ hasAccessToExplore: false });
ctrl.datasource = { meta: { explore: true } } as any;
expect((await ctrl.getAdditionalMenuItems()).length).toBe(0);
});
});
describe('and has datasource set that supports explore and user has access to explore', () => {
it('should return one item', async () => {
const ctrl = setupController({ hasAccessToExplore: true });
ctrl.datasource = { meta: { explore: true } } as any;
expect((await ctrl.getAdditionalMenuItems()).length).toBe(1);
});
describe('can setup', () => {
it('should return controller', async () => {
const ctrl = setupController({ hasAccessToExplore: true });
expect((await ctrl.getAdditionalMenuItems()).length).toBe(0);
});
});
});

View File

@@ -62,7 +62,12 @@ export default class InfluxQueryModel {
}
addGroupBy(value: string) {
const stringParts = value.match(/^(\w+)\((.*)\)$/);
let stringParts = value.match(/^(\w+)\((.*)\)$/);
if (!stringParts || !this.target.groupBy) {
return;
}
const typePart = stringParts[1];
const arg = stringParts[2];
const partModel = queryPart.create({ type: typePart, params: [arg] });

View File

@@ -128,36 +128,33 @@ function checkOppositeSides(yLeft: AxisSide, yRight: AxisSide) {
return (yLeft.min >= 0 && yRight.max <= 0) || (yLeft.max <= 0 && yRight.min >= 0);
}
function getRate(yLeft: AxisSide, yRight: AxisSide) {
let rateLeft, rateRight, rate;
function getRate(yLeft: AxisSide, yRight: AxisSide): number {
if (checkTwoCross(yLeft, yRight)) {
rateLeft = yRight.min ? yLeft.min / yRight.min : 0;
rateRight = yRight.max ? yLeft.max / yRight.max : 0;
} else {
if (checkOneSide(yLeft, yRight)) {
const absLeftMin = Math.abs(yLeft.min);
const absLeftMax = Math.abs(yLeft.max);
const absRightMin = Math.abs(yRight.min);
const absRightMax = Math.abs(yRight.max);
const upLeft = _.max([absLeftMin, absLeftMax]);
const downLeft = _.min([absLeftMin, absLeftMax]);
const upRight = _.max([absRightMin, absRightMax]);
const downRight = _.min([absRightMin, absRightMax]);
const rateLeft = yRight.min ? yLeft.min / yRight.min : 0;
const rateRight = yRight.max ? yLeft.max / yRight.max : 0;
rateLeft = downLeft ? upLeft / downLeft : upLeft;
rateRight = downRight ? upRight / downRight : upRight;
} else {
if (yLeft.min > 0 || yRight.min > 0) {
rateLeft = yLeft.max / yRight.max;
rateRight = 0;
} else {
rateLeft = 0;
rateRight = yLeft.min / yRight.min;
}
}
return rateLeft > rateRight ? rateLeft : rateRight;
}
rate = rateLeft > rateRight ? rateLeft : rateRight;
if (checkOneSide(yLeft, yRight)) {
const absLeftMin = Math.abs(yLeft.min);
const absLeftMax = Math.abs(yLeft.max);
const absRightMin = Math.abs(yRight.min);
const absRightMax = Math.abs(yRight.max);
const upLeft = _.max([absLeftMin, absLeftMax]);
const downLeft = _.min([absLeftMin, absLeftMax]);
const upRight = _.max([absRightMin, absRightMax]);
const downRight = _.min([absRightMin, absRightMax]);
return rate;
const rateLeft = downLeft ? upLeft / downLeft : upLeft;
const rateRight = downRight ? upRight / downRight : upRight;
return rateLeft > rateRight ? rateLeft : rateRight;
}
if (yLeft.min > 0 || yRight.min > 0) {
return yLeft.max / yRight.max;
} else {
return yLeft.min / yRight.min;
}
}

View File

@@ -244,6 +244,13 @@ class GraphCtrl extends MetricsPanelCtrl {
this.loading = false;
this.alertState = result.alertState;
this.annotations = result.annotations;
// Temp alerting & react hack
// Add it to the seriesList so react can access it
if (this.alertState) {
(this.seriesList as any).alertState = this.alertState.state;
}
this.render(this.seriesList);
},
() => {

View File

@@ -55,6 +55,7 @@ describe('grafanaGraph', () => {
panel: {
events: {
on: () => {},
emit: () => {},
},
legend: {},
grid: {},

View File

@@ -25,6 +25,7 @@ describe('GraphCtrl', () => {
GraphCtrl.prototype.panel = {
events: {
on: () => {},
emit: () => {},
},
gridPos: {
w: 100,

View File

@@ -577,8 +577,8 @@ export class HeatmapRenderer {
highlightCard(event: any) {
const color = d3.select(event.target).style('fill');
const highlightColor = d3.color(color).darker(2);
const strokeColor = d3.color(color).brighter(4);
const highlightColor = d3.color(color)!.darker(2);
const strokeColor = d3.color(color)!.brighter(4);
const currentCard = d3.select(event.target);
this.tooltip.originalFillColor = color;
currentCard

View File

@@ -348,9 +348,12 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const panel = ctrl.panel;
const templateSrv = this.templateSrv;
let linkInfo: LinkModel<any> | null = null;
const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel');
function getPanelContainer() {
return elem.closest('.panel-container');
}
function applyColoringThresholds(valueString: string) {
const data = ctrl.data;
const color = getColorForValue(data, data.value);
@@ -588,18 +591,18 @@ class SingleStatCtrl extends MetricsPanelCtrl {
if (panel.colorBackground) {
const color = getColorForValue(data, data.display.numeric);
if (color) {
$panelContainer.css('background-color', color);
getPanelContainer().css('background-color', color);
if (scope.fullscreen) {
elem.css('background-color', color);
} else {
elem.css('background-color', '');
}
} else {
$panelContainer.css('background-color', '');
getPanelContainer().css('background-color', '');
elem.css('background-color', '');
}
} else {
$panelContainer.css('background-color', '');
getPanelContainer().css('background-color', '');
elem.css('background-color', '');
}

View File

@@ -26,23 +26,21 @@ describe('SingleStatCtrl', () => {
const $sanitize = {};
SingleStatCtrl.prototype.panel = {
events: {
on: () => {},
emit: () => {},
},
};
SingleStatCtrl.prototype.dashboard = ({
getTimezone: jest.fn(() => 'utc'),
} as any) as DashboardModel;
SingleStatCtrl.prototype.events = {
on: () => {},
};
function singleStatScenario(desc: string, func: any) {
describe(desc, () => {
ctx.setup = (setupFunc: any) => {
beforeEach(() => {
SingleStatCtrl.prototype.panel = {
events: {
on: () => {},
emit: () => {},
},
};
// @ts-ignore
ctx.ctrl = new SingleStatCtrl($scope, $injector, {} as LinkSrv, $sanitize);
setupFunc();
@@ -335,9 +333,6 @@ describe('SingleStatCtrl', () => {
singleStatScenario('with default values', (ctx: TestContext) => {
ctx.setup(() => {
ctx.input = tableData;
ctx.ctrl.panel = {
emit: () => {},
};
ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.format = 'none';
});
@@ -386,7 +381,7 @@ describe('SingleStatCtrl', () => {
ctx.setup(() => {
ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 10, 'ignore2'];
ctx.ctrl.panel.mappingType = 2;
ctx.ctrl.panel.mappingType = 1;
ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }];
});

View File

@@ -6,12 +6,11 @@ import { transformDataToTable } from './transformers';
import { tablePanelEditor } from './editor';
import { columnOptionsTab } from './column_options';
import { TableRenderer } from './renderer';
import { isTableData } from '@grafana/data';
import { isTableData, PanelEvents, PanelPlugin } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { PanelEvents } from '@grafana/data';
import { CoreEvents } from 'app/types';
class TablePanelCtrl extends MetricsPanelCtrl {
export class TablePanelCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
pageIndex: number;
@@ -280,4 +279,6 @@ class TablePanelCtrl extends MetricsPanelCtrl {
}
}
export { TablePanelCtrl, TablePanelCtrl as PanelCtrl };
export const plugin = new PanelPlugin(null);
plugin.angularPanelCtrl = TablePanelCtrl;
plugin.setNoPadding();

View File

@@ -53,7 +53,7 @@ $panel-header-no-title-zindex: 1;
}
.panel-menu-container {
width: 1px;
width: 0px;
height: 19px;
display: inline-block;
}

View File

@@ -63,7 +63,7 @@
}
td {
padding: 0.45em 0 0.45em 1.1em;
padding: 0.45em 1.1em;
border-bottom: 2px solid $body-bg;
border-right: 2px solid $body-bg;

View File

@@ -28,7 +28,15 @@
border: 0;
}
.panel-alert-icon {
display: none;
}
.panel-has-alert {
.panel-alert-icon {
display: inline-block;
}
.panel-alert-icon:before {
content: '\e611';
position: relative;
@@ -40,7 +48,6 @@
.panel-alert-state {
&--alerting {
box-shadow: 0 0 10px rgba($critical, 0.5);
position: relative;
.panel-alert-icon:before {
color: $critical;