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
+1
View File
@@ -136,6 +136,7 @@ export interface PanelMenuItem {
iconClassName?: string; iconClassName?: string;
onClick?: (event: React.MouseEvent<any>) => void; onClick?: (event: React.MouseEvent<any>) => void;
shortcut?: string; shortcut?: string;
href?: string;
subMenu?: PanelMenuItem[]; subMenu?: PanelMenuItem[];
} }
@@ -1,5 +1,6 @@
import { eventFactory } from './utils'; import { eventFactory } from './utils';
import { DataQueryError, DataQueryResponseData } from './datasource'; import { DataQueryError, DataQueryResponseData } from './datasource';
import { AngularPanelMenuItem } from './panel';
/** Payloads */ /** Payloads */
export interface PanelChangeViewPayload { export interface PanelChangeViewPayload {
@@ -9,13 +10,6 @@ export interface PanelChangeViewPayload {
toggle?: boolean; toggle?: boolean;
} }
export interface MenuElement {
text: string;
click: string;
role?: string;
shortcut?: string;
}
/** Events */ /** Events */
export const refresh = eventFactory('refresh'); 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 dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load'); export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode'); 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 panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized'); export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed'); export const panelSizeChanged = eventFactory('panel-size-changed');
+14 -14
View File
@@ -14,7 +14,7 @@ import StateHistory from './StateHistory';
import 'app/features/alerting/AlertTabCtrl'; import 'app/features/alerting/AlertTabCtrl';
import { DashboardModel } from '../dashboard/state/DashboardModel'; import { DashboardModel } from '../dashboard/state/DashboardModel';
import { PanelModel } from '../dashboard/state/PanelModel'; import { PanelModel, angularPanelUpdated } from '../dashboard/state/PanelModel';
import { TestRuleResult } from './TestRuleResult'; import { TestRuleResult } from './TestRuleResult';
import { AppNotificationSeverity, StoreState } from 'app/types'; import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers'; 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'; import { CoreEvents } from 'app/types';
interface Props { interface Props {
angularPanel?: AngularComponent;
dashboard: DashboardModel; dashboard: DashboardModel;
panel: PanelModel; panel: PanelModel;
changePanelEditorTab: typeof changePanelEditorTab; changePanelEditorTab: typeof changePanelEditorTab;
@@ -42,19 +41,16 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
}; };
componentDidMount() { componentDidMount() {
if (this.shouldLoadAlertTab()) { this.loadAlertTab();
this.loadAlertTab(); this.props.panel.events.on(angularPanelUpdated, this.onAngularPanelUpdated);
}
} }
onAngularPanelUpdated = () => {
this.forceUpdate();
};
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
if (this.shouldLoadAlertTab()) { this.loadAlertTab();
this.loadAlertTab();
}
}
shouldLoadAlertTab() {
return this.props.angularPanel && this.element && !this.component;
} }
componentWillUnmount() { componentWillUnmount() {
@@ -64,9 +60,13 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
} }
async loadAlertTab() { 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 // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) { if (!scope.$$childHead) {
@@ -90,6 +90,7 @@ export class AnnotationsSrv {
this.alertStatesPromise = getBackendSrv().get('/api/alerts/states-for-dashboard', { this.alertStatesPromise = getBackendSrv().get('/api/alerts/states-for-dashboard', {
dashboardId: options.dashboard.id, dashboardId: options.dashboard.id,
}); });
return this.alertStatesPromise; return this.alertStatesPromise;
} }
@@ -183,6 +183,7 @@ export class DashboardPage extends PureComponent<Props, State> {
try { try {
this.props.dashboard.render(); this.props.dashboard.render();
} catch (err) { } catch (err) {
console.error(err);
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err)); this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
} }
} }
@@ -4,7 +4,6 @@ import classNames from 'classnames';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
// Utils & Services // Utils & Services
import { getAngularLoader, AngularComponent } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
// Components // Components
@@ -13,6 +12,7 @@ import { DashboardRow } from '../components/DashboardRow';
import { PanelChrome } from './PanelChrome'; import { PanelChrome } from './PanelChrome';
import { PanelEditor } from '../panel_editor/PanelEditor'; import { PanelEditor } from '../panel_editor/PanelEditor';
import { PanelResizer } from './PanelResizer'; import { PanelResizer } from './PanelResizer';
import { PanelChromeAngular } from './PanelChromeAngular';
// Types // Types
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
@@ -29,7 +29,6 @@ export interface Props {
export interface State { export interface State {
plugin: PanelPlugin; plugin: PanelPlugin;
angularPanel: AngularComponent;
isLazy: boolean; isLazy: boolean;
} }
@@ -42,7 +41,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
this.state = { this.state = {
plugin: null, plugin: null,
angularPanel: null,
isLazy: !props.isInView, isLazy: !props.isInView,
}; };
@@ -77,16 +75,13 @@ export class DashboardPanel extends PureComponent<Props, State> {
if (!this.state.plugin || this.state.plugin.meta.id !== pluginId) { if (!this.state.plugin || this.state.plugin.meta.id !== pluginId) {
const plugin = await importPanelPlugin(pluginId); const plugin = await importPanelPlugin(pluginId);
// unmount angular panel
this.cleanUpAngularPanel();
if (panel.type !== pluginId) { if (panel.type !== pluginId) {
panel.changePlugin(plugin); panel.changePlugin(plugin);
} else { } else {
panel.pluginLoaded(plugin); 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) { if (this.state.isLazy && this.props.isInView) {
this.setState({ isLazy: false }); 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 = () => { onMouseEnter = () => {
@@ -134,10 +107,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props; const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props;
const { plugin } = this.state; const { plugin } = this.state;
if (plugin.angularPanelCtrl) {
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
}
return ( return (
<AutoSizer> <AutoSizer>
{({ width, height }) => { {({ width, height }) => {
@@ -145,6 +114,20 @@ export class DashboardPanel extends PureComponent<Props, State> {
return null; return null;
} }
if (plugin.angularPanelCtrl) {
return (
<PanelChromeAngular
plugin={plugin}
panel={panel}
dashboard={dashboard}
isFullscreen={isFullscreen}
isInView={isInView}
width={width}
height={height}
/>
);
}
return ( return (
<PanelChrome <PanelChrome
plugin={plugin} plugin={plugin}
@@ -164,7 +147,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
render() { render() {
const { panel, dashboard, isFullscreen, isEditing } = this.props; const { panel, dashboard, isFullscreen, isEditing } = this.props;
const { plugin, angularPanel, isLazy } = this.state; const { plugin, isLazy } = this.state;
if (this.isSpecial(panel.type)) { if (this.isSpecial(panel.type)) {
return this.specialPanels[panel.type](); return this.specialPanels[panel.type]();
@@ -212,7 +195,6 @@ export class DashboardPanel extends PureComponent<Props, State> {
panel={panel} panel={panel}
plugin={plugin} plugin={plugin}
dashboard={dashboard} dashboard={dashboard}
angularPanel={angularPanel}
onPluginTypeChange={this.onPluginTypeChange} onPluginTypeChange={this.onPluginTypeChange}
/> />
)} )}
@@ -272,7 +272,7 @@ export class PanelChrome extends PureComponent<Props, State> {
// do not render component until we have first data // do not render component until we have first data
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) { if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return this.renderLoadingState(); return null;
} }
const PanelComponent = plugin.panel; const PanelComponent = plugin.panel;
@@ -290,7 +290,6 @@ export class PanelChrome extends PureComponent<Props, State> {
return ( return (
<> <>
{loading === LoadingState.Loading && this.renderLoadingState()}
<div className={panelContentClassNames}> <div className={panelContentClassNames}>
<PanelComponent <PanelComponent
id={panel.id} 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() { hasOverlayHeader() {
const { panel } = this.props; const { panel } = this.props;
const { errorMessage, data } = this.state; const { errorMessage, data } = this.state;
@@ -360,6 +351,7 @@ export class PanelChrome extends PureComponent<Props, State> {
links={panel.links} links={panel.links}
error={errorMessage} error={errorMessage}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading}
/> />
<ErrorBoundary> <ErrorBoundary>
{({ error, errorInfo }) => { {({ error, errorInfo }) => {
@@ -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>
);
}
}
@@ -1,7 +1,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { DataLink, ScopedVars } from '@grafana/data'; import { DataLink, ScopedVars, PanelMenuItem } from '@grafana/data';
import { ClickOutsideWrapper } from '@grafana/ui'; import { ClickOutsideWrapper } from '@grafana/ui';
import { e2e } from '@grafana/e2e'; 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 { 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';
export interface Props { export interface Props {
panel: PanelModel; panel: PanelModel;
@@ -23,6 +24,7 @@ export interface Props {
links?: DataLink[]; links?: DataLink[];
error?: string; error?: string;
isFullscreen: boolean; isFullscreen: boolean;
isLoading: boolean;
} }
interface ClickCoordinates { interface ClickCoordinates {
@@ -32,13 +34,15 @@ interface ClickCoordinates {
interface State { interface State {
panelMenuOpen: boolean; panelMenuOpen: boolean;
menuItems: PanelMenuItem[];
} }
export class PanelHeader extends Component<Props, State> { export class PanelHeader extends Component<Props, State> {
clickCoordinates: ClickCoordinates = { x: 0, y: 0 }; clickCoordinates: ClickCoordinates = { x: 0, y: 0 };
state = {
state: State = {
panelMenuOpen: false, panelMenuOpen: false,
clickCoordinates: { x: 0, y: 0 }, menuItems: [],
}; };
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => { eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -57,13 +61,19 @@ export class PanelHeader extends Component<Props, State> {
}; };
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => { onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.isClick(this.eventToClickCoordinates(event))) { if (!this.isClick(this.eventToClickCoordinates(event))) {
event.stopPropagation(); return;
this.setState(prevState => ({
panelMenuOpen: !prevState.panelMenuOpen,
}));
} }
event.stopPropagation();
const { dashboard, panel } = this.props;
const menuItems = getPanelMenu(dashboard, panel);
this.setState({
panelMenuOpen: !this.state.panelMenuOpen,
menuItems,
});
}; };
closeMenu = () => { 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() { 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 title = templateSrv.replaceWithText(panel.title, scopedVars);
const panelHeaderClass = classNames({ const panelHeaderClass = classNames({
@@ -83,6 +102,7 @@ export class PanelHeader extends Component<Props, State> {
return ( return (
<> <>
{isLoading && this.renderLoadingState()}
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
<PanelHeaderCorner <PanelHeaderCorner
panel={panel} panel={panel}
@@ -105,7 +125,7 @@ export class PanelHeader extends Component<Props, State> {
</span> </span>
{this.state.panelMenuOpen && ( {this.state.panelMenuOpen && (
<ClickOutsideWrapper onClick={this.closeMenu}> <ClickOutsideWrapper onClick={this.closeMenu}>
<PanelHeaderMenu panel={panel} dashboard={dashboard} /> <PanelHeaderMenu items={menuItems} />
</ClickOutsideWrapper> </ClickOutsideWrapper>
)} )}
{timeInfo && ( {timeInfo && (
@@ -1,13 +1,9 @@
import React, { PureComponent } from 'react'; 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 { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { PanelMenuItem } from '@grafana/data'; import { PanelMenuItem } from '@grafana/data';
export interface Props { export interface Props {
panel: PanelModel; items: PanelMenuItem[];
dashboard: DashboardModel;
} }
export class PanelHeaderMenu extends PureComponent<Props> { export class PanelHeaderMenu extends PureComponent<Props> {
@@ -33,9 +29,6 @@ export class PanelHeaderMenu extends PureComponent<Props> {
}; };
render() { render() {
const { dashboard, panel } = this.props; return <div className="panel-menu-container dropdown open">{this.renderItems(this.props.items)}</div>;
const menu = getPanelMenu(dashboard, panel);
return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
} }
} }
@@ -13,7 +13,7 @@ export const PanelHeaderMenuItem: FC<Props & PanelMenuItem> = props => {
<li className="divider" /> <li className="divider" />
) : ( ) : (
<li className={isSubMenu ? 'dropdown-submenu' : null}> <li className={isSubMenu ? 'dropdown-submenu' : null}>
<a onClick={props.onClick}> <a onClick={props.onClick} href={props.href}>
{props.iconClassName && <i className={props.iconClassName} />} {props.iconClassName && <i className={props.iconClassName} />}
<span <span
className="dropdown-item-text" className="dropdown-item-text"
@@ -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)} />;
}
}
@@ -4,7 +4,7 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Tooltip } from '@grafana/ui'; import { Tooltip } from '@grafana/ui';
import { PanelPlugin, PanelPluginMeta } from '@grafana/data'; import { PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { AngularComponent, config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { e2e } from '@grafana/e2e'; import { e2e } from '@grafana/e2e';
import { QueriesTab } from './QueriesTab'; import { QueriesTab } from './QueriesTab';
@@ -22,7 +22,6 @@ interface PanelEditorProps {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
plugin: PanelPlugin; plugin: PanelPlugin;
angularPanel?: AngularComponent;
onPluginTypeChange: (newType: PanelPluginMeta) => void; onPluginTypeChange: (newType: PanelPluginMeta) => void;
activeTab: PanelEditorTabIds; activeTab: PanelEditorTabIds;
tabs: PanelEditorTab[]; tabs: PanelEditorTab[];
@@ -71,15 +70,14 @@ class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
}; };
renderCurrentTab(activeTab: string) { renderCurrentTab(activeTab: string) {
const { panel, dashboard, plugin, angularPanel } = this.props; const { panel, dashboard, plugin } = this.props;
switch (activeTab) { switch (activeTab) {
case 'advanced': case 'advanced':
return <GeneralTab panel={panel} />; return <GeneralTab panel={panel} />;
case 'queries': case 'queries':
return <QueriesTab panel={panel} dashboard={dashboard} />; return <QueriesTab panel={panel} dashboard={dashboard} />;
case 'alert': case 'alert':
return <AlertTab angularPanel={angularPanel} dashboard={dashboard} panel={panel} />; return <AlertTab dashboard={dashboard} panel={panel} />;
case 'visualization': case 'visualization':
return ( return (
<VisualizationTab <VisualizationTab
@@ -87,7 +85,6 @@ class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
dashboard={dashboard} dashboard={dashboard}
plugin={plugin} plugin={plugin}
onPluginTypeChange={this.onPluginTypeChange} onPluginTypeChange={this.onPluginTypeChange}
angularPanel={angularPanel}
/> />
); );
default: default:
@@ -1,8 +1,8 @@
// Libraries // Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Utils & Services // Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime'; import { AngularComponent } from '@grafana/runtime';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; import { connect } from 'react-redux';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
// Components // Components
@@ -10,11 +10,11 @@ import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { VizTypePicker } from './VizTypePicker'; import { VizTypePicker } from './VizTypePicker';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { FadeIn } from 'app/core/components/Animations/FadeIn'; import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { AngularPanelOptions } from './AngularPanelOptions';
// Types // Types
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
import { VizPickerSearch } from './VizPickerSearch'; import { VizPickerSearch } from './VizPickerSearch';
import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
import { PanelCtrl } from 'app/plugins/sdk';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { PanelPlugin, PanelPluginMeta, PanelData, LoadingState, DefaultTimeRange } from '@grafana/data'; import { PanelPlugin, PanelPluginMeta, PanelData, LoadingState, DefaultTimeRange } from '@grafana/data';
@@ -22,7 +22,6 @@ interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
plugin: PanelPlugin; plugin: PanelPlugin;
angularPanel?: AngularComponent;
onPluginTypeChange: (newType: PanelPluginMeta) => void; onPluginTypeChange: (newType: PanelPluginMeta) => void;
updateLocation: typeof updateLocation; updateLocation: typeof updateLocation;
urlOpenVizPicker: boolean; urlOpenVizPicker: boolean;
@@ -63,10 +62,17 @@ export class VisualizationTab extends PureComponent<Props, State> {
}; };
renderPanelOptions() { renderPanelOptions() {
const { plugin, angularPanel } = this.props; const { plugin, dashboard, panel } = this.props;
if (angularPanel) { if (plugin.angularPanelCtrl) {
return <div ref={element => (this.element = element)} />; return (
<AngularPanelOptions
plugin={plugin}
dashboard={dashboard}
panel={panel}
onPluginTypeChange={this.onPluginTypeChange}
/>
);
} }
if (plugin.editor) { if (plugin.editor) {
@@ -85,82 +91,16 @@ export class VisualizationTab extends PureComponent<Props, State> {
componentDidMount() { componentDidMount() {
const { panel } = this.props; const { panel } = this.props;
const queryRunner = panel.getQueryRunner(); const queryRunner = panel.getQueryRunner();
if (this.shouldLoadAngularOptions()) {
this.loadAngularOptions();
}
this.querySubscription = queryRunner.getData().subscribe({ this.querySubscription = queryRunner.getData().subscribe({
next: (data: PanelData) => this.setState({ data }), 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() { componentWillUnmount() {
if (this.querySubscription) { if (this.querySubscription) {
this.querySubscription.unsubscribe(); this.querySubscription.unsubscribe();
} }
this.cleanUpAngularOptions();
}
cleanUpAngularOptions() {
if (this.angularOptions) {
this.angularOptions.destroy();
this.angularOptions = null;
}
} }
clearQuery = () => { clearQuery = () => {
@@ -276,4 +216,4 @@ const mapDispatchToProps = {
updateLocation, updateLocation,
}; };
export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps); export default connect(mapStateToProps, mapDispatchToProps)(VisualizationTab);
@@ -1,6 +1,5 @@
import { PanelModel } from './PanelModel'; import { PanelModel } from './PanelModel';
import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks';
import { PanelEvents } from '@grafana/data';
class TablePanelCtrl {} class TablePanelCtrl {}
@@ -148,18 +147,19 @@ describe('PanelModel', () => {
}); });
describe('when changing from angular panel', () => { describe('when changing from angular panel', () => {
let tearDownPublished = false; const angularPanel = {
scope: {},
destroy: jest.fn(),
};
beforeEach(() => { beforeEach(() => {
model.events.on(PanelEvents.panelTeardown, () => { model.angularPanel = angularPanel;
tearDownPublished = true;
});
model.changePlugin(getPanelPlugin({ id: 'graph' })); model.changePlugin(getPanelPlugin({ id: 'graph' }));
}); });
it('should teardown / destroy panel so angular panels event subscriptions are removed', () => { it('should set angularPanel to undefined and call destory', () => {
expect(tearDownPublished).toBe(true); expect(angularPanel.destroy.mock.calls.length).toBe(1);
expect(model.events.getEventCount()).toBe(0); expect(model.angularPanel).toBe(undefined);
}); });
}); });
@@ -13,6 +13,7 @@ import {
DataTransformerConfig, DataTransformerConfig,
ScopedVars, ScopedVars,
} from '@grafana/data'; } from '@grafana/data';
import { AngularComponent } from '@grafana/runtime';
import config from 'app/core/config'; 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 panelAdded = eventFactory<PanelModel | undefined>('panel-added');
export const panelRemoved = eventFactory<PanelModel | undefined>('panel-removed'); export const panelRemoved = eventFactory<PanelModel | undefined>('panel-removed');
export const angularPanelUpdated = eventFactory('panel-angular-panel-updated');
export interface GridPos { export interface GridPos {
x: number; x: number;
@@ -41,6 +43,7 @@ const notPersistedProperties: { [str: string]: boolean } = {
cachedPluginOptions: true, cachedPluginOptions: true,
plugin: true, plugin: true,
queryRunner: true, queryRunner: true,
angularPanel: true,
restoreModel: true, restoreModel: true,
}; };
@@ -135,6 +138,8 @@ export class PanelModel {
cachedPluginOptions?: any; cachedPluginOptions?: any;
legend?: { show: boolean }; legend?: { show: boolean };
plugin?: PanelPlugin; plugin?: PanelPlugin;
angularPanel?: AngularComponent;
private queryRunner?: PanelQueryRunner; private queryRunner?: PanelQueryRunner;
constructor(model: any) { constructor(model: any) {
@@ -290,9 +295,8 @@ export class PanelModel {
const oldPluginId = this.type; const oldPluginId = this.type;
const wasAngular = !!this.plugin.angularPanelCtrl; const wasAngular = !!this.plugin.angularPanelCtrl;
// for angular panels we must remove all events and let angular panels do some cleanup if (this.angularPanel) {
if (wasAngular) { this.setAngularPanel(undefined);
this.destroy();
} }
// remove panel type specific options // remove panel type specific options
@@ -380,19 +384,32 @@ export class PanelModel {
} }
destroy() { destroy() {
this.events.emit(PanelEvents.panelTeardown);
this.events.removeAllListeners(); this.events.removeAllListeners();
if (this.queryRunner) { if (this.queryRunner) {
this.queryRunner.destroy(); this.queryRunner.destroy();
this.queryRunner = null; this.queryRunner = null;
} }
if (this.angularPanel) {
this.angularPanel.destroy();
}
} }
setTransformations(transformations: DataTransformerConfig[]) { setTransformations(transformations: DataTransformerConfig[]) {
this.transformations = transformations; this.transformations = transformations;
this.getQueryRunner().setTransformations(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 { function getPluginVersion(plugin: PanelPlugin): string {
@@ -26,13 +26,13 @@ const dashbardSlice = createSlice({
loadDashboardPermissions: (state, action: PayloadAction<DashboardAclDTO[]>) => { loadDashboardPermissions: (state, action: PayloadAction<DashboardAclDTO[]>) => {
state.permissions = processAclItems(action.payload); state.permissions = processAclItems(action.payload);
}, },
dashboardInitFetching: state => { dashboardInitFetching: (state, action: PayloadAction) => {
state.initPhase = DashboardInitPhase.Fetching; state.initPhase = DashboardInitPhase.Fetching;
}, },
dashboardInitServices: state => { dashboardInitServices: (state, action: PayloadAction) => {
state.initPhase = DashboardInitPhase.Services; state.initPhase = DashboardInitPhase.Services;
}, },
dashboardInitSlow: state => { dashboardInitSlow: (state, action: PayloadAction) => {
state.isInitSlow = true; state.isInitSlow = true;
}, },
dashboardInitCompleted: (state, action: PayloadAction<MutableDashboard>) => { dashboardInitCompleted: (state, action: PayloadAction<MutableDashboard>) => {
@@ -41,16 +41,13 @@ const dashbardSlice = createSlice({
state.isInitSlow = false; state.isInitSlow = false;
}, },
dashboardInitFailed: (state, action: PayloadAction<DashboardInitError>) => { dashboardInitFailed: (state, action: PayloadAction<DashboardInitError>) => {
const failedDashboard = new DashboardModel(
{ title: 'Dashboard init failed' },
{ canSave: false, canEdit: false }
);
state.initPhase = DashboardInitPhase.Failed; state.initPhase = DashboardInitPhase.Failed;
state.initError = action.payload; 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()) { if (state.getModel()) {
state.getModel().destroy(); state.getModel().destroy();
state.getModel = () => null; state.getModel = () => null;
@@ -63,7 +60,7 @@ const dashbardSlice = createSlice({
setDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction<QueriesToUpdateOnDashboardLoad>) => { setDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction<QueriesToUpdateOnDashboardLoad>) => {
state.modifiedQueries = action.payload; state.modifiedQueries = action.payload;
}, },
clearDashboardQueriesToUpdateOnLoad: state => { clearDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => {
state.modifiedQueries = null; state.modifiedQueries = null;
}, },
}, },
@@ -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",
},
]
`);
});
});
@@ -3,7 +3,6 @@ import { store } from 'app/store/store';
import config from 'app/core/config'; import config from 'app/core/config';
import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime'; import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data'; import { PanelMenuItem } from '@grafana/data';
import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel'; import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; 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 { navigateToExplore } from '../../explore/state/actions';
import { getExploreUrl } from '../../../core/utils/explore'; import { getExploreUrl } from '../../../core/utils/explore';
import { getTimeSrv } from '../services/TimeSrv'; 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>) => { const onViewPanel = (event: React.MouseEvent<any>) => {
event.preventDefault(); event.preventDefault();
store.dispatch( store.dispatch(
@@ -123,7 +123,7 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
shortcut: 'p s', shortcut: 'p s',
}); });
if (contextSrv.hasAccessToExplore() && panel.datasource) { if (contextSrv.hasAccessToExplore() && !panel.plugin.meta.skipDataQuery) {
menu.push({ menu.push({
text: 'Explore', text: 'Explore',
iconClassName: 'gicon gicon-explore', iconClassName: 'gicon gicon-explore',
@@ -170,6 +170,29 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
onClick: onEditPanelJson, 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({ menu.push({
type: 'submenu', type: 'submenu',
text: 'More...', text: 'More...',
@@ -190,4 +213,4 @@ export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
} }
return menu; return menu;
}; }
-1
View File
@@ -1,4 +1,3 @@
import './panel_header';
import './panel_directive'; import './panel_directive';
import './query_ctrl'; import './query_ctrl';
import './panel_editor_tab'; import './panel_editor_tab';
@@ -1,10 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import { getExploreUrl } from 'app/core/utils/explore'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
import { ContextSrv } from 'app/core/services/context_srv'; import { ContextSrv } from 'app/core/services/context_srv';
import { import {
DataFrame, DataFrame,
@@ -164,32 +160,14 @@ class MetricsPanelCtrl extends PanelCtrl {
updateTimeRange(datasource?: DataSourceApi) { updateTimeRange(datasource?: DataSourceApi) {
this.datasource = datasource || this.datasource; this.datasource = datasource || this.datasource;
this.range = this.timeSrv.timeRange(); this.range = this.timeSrv.timeRange();
this.resolution = getResolution(this.panel);
const newTimeData = applyPanelTimeOverrides(this.panel, this.range); const newTimeData = applyPanelTimeOverrides(this.panel, this.range);
this.timeInfo = newTimeData.timeInfo; this.timeInfo = newTimeData.timeInfo;
this.range = newTimeData.timeRange; this.range = newTimeData.timeRange;
this.calculateInterval();
return this.datasource; 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) { issueQueries(datasource: DataSourceApi) {
this.datasource = datasource; this.datasource = datasource;
@@ -206,8 +184,9 @@ class MetricsPanelCtrl extends PanelCtrl {
panelId: panel.id, panelId: panel.id,
dashboardId: this.dashboard.id, dashboardId: this.dashboard.id,
timezone: this.dashboard.timezone, timezone: this.dashboard.timezone,
timeInfo: this.timeInfo,
timeRange: this.range, timeRange: this.range,
widthPixels: this.resolution, // The pixel width widthPixels: this.width,
maxDataPoints: panel.maxDataPoints, maxDataPoints: panel.maxDataPoints,
minInterval: panel.interval, minInterval: panel.interval,
scopedVars: panel.scopedVars, scopedVars: panel.scopedVars,
@@ -248,25 +227,6 @@ class MetricsPanelCtrl extends PanelCtrl {
this.processDataError(err); 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 }; export { MetricsPanelCtrl };
+6 -204
View File
@@ -1,23 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import { escapeHtml, sanitize } from 'app/core/utils/text';
import config from 'app/core/config'; import config from 'app/core/config';
import { Emitter, profiler } from 'app/core/core'; import { profiler } from 'app/core/core';
import getFactors from 'app/core/utils/factors'; import { Emitter } from 'app/core/utils/emitter';
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 { auto } from 'angular'; import { auto } from 'angular';
import { TemplateSrv } from '../templating/template_srv'; import { AppEvent, PanelEvents, PanelPluginMeta, AngularPanelMenuItem } from '@grafana/data';
import { getPanelLinksSupplier } from './panellinks/linkSuppliers';
import { AppEvent, PanelEvents, PanelPluginMeta, renderMarkdown } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
import { DashboardModel } from '../dashboard/state'; import { DashboardModel } from '../dashboard/state';
export class PanelCtrl { export class PanelCtrl {
@@ -32,12 +18,12 @@ export class PanelCtrl {
$location: any; $location: any;
$timeout: any; $timeout: any;
editModeInitiated: boolean; editModeInitiated: boolean;
height: any; height: number;
width: number;
containerHeight: any; containerHeight: any;
events: Emitter; events: Emitter;
loading: boolean; loading: boolean;
timing: any; timing: any;
maxPanelsPerRowOptions: number[];
constructor($scope: any, $injector: auto.IInjectorService) { constructor($scope: any, $injector: auto.IInjectorService) {
this.$injector = $injector; this.$injector = $injector;
@@ -74,31 +60,10 @@ export class PanelCtrl {
this.$scope.$root.appEvent(event, payload); 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() { initEditMode() {
if (!this.editModeInitiated) { if (!this.editModeInitiated) {
this.editModeInitiated = true; this.editModeInitiated = true;
this.events.emit(PanelEvents.editModeInitialized); 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() { getExtendedMenu() {
const menu = []; const menu: AngularPanelMenuItem[] = [];
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();',
});
this.events.emit(PanelEvents.initPanelActions, menu); this.events.emit(PanelEvents.initPanelActions, menu);
return menu; return menu;
} }
@@ -213,93 +98,10 @@ export class PanelCtrl {
return this.dashboard.meta.fullscreen && !this.panel.fullscreen; return this.dashboard.meta.fullscreen && !this.panel.fullscreen;
} }
calculatePanelHeight(containerHeight: number) {
this.containerHeight = containerHeight;
this.height = calculateInnerPanelHeight(this.panel, containerHeight);
}
render(payload?: any) { render(payload?: any) {
this.events.emit(PanelEvents.render, payload); 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 // overriden from react
onPluginTypeChange = (plugin: PanelPluginMeta) => {}; onPluginTypeChange = (plugin: PanelPluginMeta) => {};
} }
+26 -162
View File
@@ -1,35 +1,14 @@
import angular from 'angular'; import angular from 'angular';
import $ from 'jquery';
// @ts-ignore
import Drop from 'tether-drop';
// @ts-ignore // @ts-ignore
import baron from 'baron'; import baron from 'baron';
import { PanelEvents } from '@grafana/data'; import { PanelEvents } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime'; import { PanelModel } from '../dashboard/state';
import { e2e } from '@grafana/e2e'; import { PanelCtrl } from './panel_ctrl';
const module = angular.module('grafana.directives'); const module = angular.module('grafana.directives');
const panelTemplate = ` const panelTemplate = `
<div class="panel-container" ng-class="{'panel-container--no-title': !ctrl.panel.title.length}"> <ng-transclude class="panel-height-helper"></ng-transclude>
<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>
`; `;
module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
@@ -39,52 +18,19 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
transclude: true, transclude: true,
scope: { ctrl: '=' }, scope: { ctrl: '=' },
link: (scope: any, elem) => { link: (scope: any, elem) => {
const panelContainer = elem.find('.panel-container'); const ctrl: PanelCtrl = scope.ctrl;
const panelContent = elem.find('.panel-content'); const panel: PanelModel = scope.ctrl.panel;
const cornerInfoElem = elem.find('.panel-info-corner');
const ctrl = scope.ctrl;
ctrl.selectors = e2e.pages.Dashboard.Panels.Panel.selectors;
let infoDrop: any;
let panelScrollbar: any; 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() { function resizeScrollableContent() {
if (panelScrollbar) { if (panelScrollbar) {
panelScrollbar.update(); 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, () => { 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 scrollRootClass = 'baron baron__root baron__clipper panel-content--scrollable';
const scrollerClass = 'baron__scroller'; const scrollerClass = 'baron__scroller';
const scrollBarHTML = ` const scrollBarHTML = `
@@ -93,8 +39,8 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
</div> </div>
`; `;
const scrollRoot = panelContent; const scrollRoot = elem;
const scroller = panelContent.find(':first').find(':first'); const scroller = elem.find(':first').find(':first');
scrollRoot.addClass(scrollRootClass); scrollRoot.addClass(scrollRootClass);
$(scrollBarHTML).appendTo(scrollRoot); $(scrollBarHTML).appendTo(scrollRoot);
@@ -112,112 +58,44 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
} }
}); });
ctrl.events.on(PanelEvents.panelSizeChanged, () => { function onPanelSizeChanged() {
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
$timeout(() => { $timeout(() => {
resizeScrollableContent(); resizeScrollableContent();
ctrl.render(); ctrl.render();
}); });
}); }
ctrl.events.on(PanelEvents.viewModeChanged, () => { function onViewModeChanged() {
// first wait one pass for dashboard fullscreen view mode to take effect (classses being applied) // first wait one pass for dashboard fullscreen view mode to take effect (classses being applied)
setTimeout(() => { setTimeout(() => {
// then recalc style
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
// then wait another cycle (this might not be needed) // then wait another cycle (this might not be needed)
$timeout(() => { $timeout(() => {
ctrl.render(); ctrl.render();
resizeScrollableContent(); resizeScrollableContent();
}); });
}, 10); }, 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); function onPanelModelRender(payload?: any) {
scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo); ctrl.height = scope.$parent.$parent.size.height;
ctrl.width = scope.$parent.$parent.size.width;
}
elem.on('mouseenter', mouseEnter); function onPanelModelRefresh() {
elem.on('mouseleave', mouseLeave); 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', () => { scope.$on('$destroy', () => {
elem.off(); elem.off();
cornerInfoElem.off();
if (infoDrop) { panel.events.emit(PanelEvents.panelTeardown);
infoDrop.destroy(); panel.events.removeAllListeners();
}
if (panelScrollbar) { if (panelScrollbar) {
panelScrollbar.dispose(); 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) => {},
};
});
-123
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);
@@ -22,31 +22,10 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { MetricsPanelCtrl } from '../metrics_panel_ctrl'; import { MetricsPanelCtrl } from '../metrics_panel_ctrl';
describe('MetricsPanelCtrl', () => { describe('MetricsPanelCtrl', () => {
describe('when getting additional menu items', () => { describe('can setup', () => {
describe('and has no datasource set but user has access to explore', () => { it('should return controller', async () => {
it('should not return any items', async () => { const ctrl = setupController({ hasAccessToExplore: true });
const ctrl = setupController({ hasAccessToExplore: true }); expect((await ctrl.getAdditionalMenuItems()).length).toBe(0);
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);
});
}); });
}); });
}); });
@@ -62,7 +62,12 @@ export default class InfluxQueryModel {
} }
addGroupBy(value: string) { addGroupBy(value: string) {
const stringParts = value.match(/^(\w+)\((.*)\)$/); let stringParts = value.match(/^(\w+)\((.*)\)$/);
if (!stringParts || !this.target.groupBy) {
return;
}
const typePart = stringParts[1]; const typePart = stringParts[1];
const arg = stringParts[2]; const arg = stringParts[2];
const partModel = queryPart.create({ type: typePart, params: [arg] }); const partModel = queryPart.create({ type: typePart, params: [arg] });
+24 -27
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); return (yLeft.min >= 0 && yRight.max <= 0) || (yLeft.max <= 0 && yRight.min >= 0);
} }
function getRate(yLeft: AxisSide, yRight: AxisSide) { function getRate(yLeft: AxisSide, yRight: AxisSide): number {
let rateLeft, rateRight, rate;
if (checkTwoCross(yLeft, yRight)) { if (checkTwoCross(yLeft, yRight)) {
rateLeft = yRight.min ? yLeft.min / yRight.min : 0; const rateLeft = yRight.min ? yLeft.min / yRight.min : 0;
rateRight = yRight.max ? yLeft.max / yRight.max : 0; const 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]);
rateLeft = downLeft ? upLeft / downLeft : upLeft; return rateLeft > rateRight ? rateLeft : rateRight;
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;
}
}
} }
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;
}
} }
+7
View File
@@ -244,6 +244,13 @@ class GraphCtrl extends MetricsPanelCtrl {
this.loading = false; this.loading = false;
this.alertState = result.alertState; this.alertState = result.alertState;
this.annotations = result.annotations; 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); this.render(this.seriesList);
}, },
() => { () => {
@@ -55,6 +55,7 @@ describe('grafanaGraph', () => {
panel: { panel: {
events: { events: {
on: () => {}, on: () => {},
emit: () => {},
}, },
legend: {}, legend: {},
grid: {}, grid: {},
@@ -25,6 +25,7 @@ describe('GraphCtrl', () => {
GraphCtrl.prototype.panel = { GraphCtrl.prototype.panel = {
events: { events: {
on: () => {}, on: () => {},
emit: () => {},
}, },
gridPos: { gridPos: {
w: 100, w: 100,
@@ -577,8 +577,8 @@ export class HeatmapRenderer {
highlightCard(event: any) { highlightCard(event: any) {
const color = d3.select(event.target).style('fill'); const color = d3.select(event.target).style('fill');
const highlightColor = d3.color(color).darker(2); const highlightColor = d3.color(color)!.darker(2);
const strokeColor = d3.color(color).brighter(4); const strokeColor = d3.color(color)!.brighter(4);
const currentCard = d3.select(event.target); const currentCard = d3.select(event.target);
this.tooltip.originalFillColor = color; this.tooltip.originalFillColor = color;
currentCard currentCard
@@ -348,9 +348,12 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const panel = ctrl.panel; const panel = ctrl.panel;
const templateSrv = this.templateSrv; const templateSrv = this.templateSrv;
let linkInfo: LinkModel<any> | null = null; let linkInfo: LinkModel<any> | null = null;
const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel'); elem = elem.find('.singlestat-panel');
function getPanelContainer() {
return elem.closest('.panel-container');
}
function applyColoringThresholds(valueString: string) { function applyColoringThresholds(valueString: string) {
const data = ctrl.data; const data = ctrl.data;
const color = getColorForValue(data, data.value); const color = getColorForValue(data, data.value);
@@ -588,18 +591,18 @@ class SingleStatCtrl extends MetricsPanelCtrl {
if (panel.colorBackground) { if (panel.colorBackground) {
const color = getColorForValue(data, data.display.numeric); const color = getColorForValue(data, data.display.numeric);
if (color) { if (color) {
$panelContainer.css('background-color', color); getPanelContainer().css('background-color', color);
if (scope.fullscreen) { if (scope.fullscreen) {
elem.css('background-color', color); elem.css('background-color', color);
} else { } else {
elem.css('background-color', ''); elem.css('background-color', '');
} }
} else { } else {
$panelContainer.css('background-color', ''); getPanelContainer().css('background-color', '');
elem.css('background-color', ''); elem.css('background-color', '');
} }
} else { } else {
$panelContainer.css('background-color', ''); getPanelContainer().css('background-color', '');
elem.css('background-color', ''); elem.css('background-color', '');
} }
@@ -26,23 +26,21 @@ describe('SingleStatCtrl', () => {
const $sanitize = {}; const $sanitize = {};
SingleStatCtrl.prototype.panel = {
events: {
on: () => {},
emit: () => {},
},
};
SingleStatCtrl.prototype.dashboard = ({ SingleStatCtrl.prototype.dashboard = ({
getTimezone: jest.fn(() => 'utc'), getTimezone: jest.fn(() => 'utc'),
} as any) as DashboardModel; } as any) as DashboardModel;
SingleStatCtrl.prototype.events = {
on: () => {},
};
function singleStatScenario(desc: string, func: any) { function singleStatScenario(desc: string, func: any) {
describe(desc, () => { describe(desc, () => {
ctx.setup = (setupFunc: any) => { ctx.setup = (setupFunc: any) => {
beforeEach(() => { beforeEach(() => {
SingleStatCtrl.prototype.panel = {
events: {
on: () => {},
emit: () => {},
},
};
// @ts-ignore // @ts-ignore
ctx.ctrl = new SingleStatCtrl($scope, $injector, {} as LinkSrv, $sanitize); ctx.ctrl = new SingleStatCtrl($scope, $injector, {} as LinkSrv, $sanitize);
setupFunc(); setupFunc();
@@ -335,9 +333,6 @@ describe('SingleStatCtrl', () => {
singleStatScenario('with default values', (ctx: TestContext) => { singleStatScenario('with default values', (ctx: TestContext) => {
ctx.setup(() => { ctx.setup(() => {
ctx.input = tableData; ctx.input = tableData;
ctx.ctrl.panel = {
emit: () => {},
};
ctx.ctrl.panel.tableColumn = 'mean'; ctx.ctrl.panel.tableColumn = 'mean';
ctx.ctrl.panel.format = 'none'; ctx.ctrl.panel.format = 'none';
}); });
@@ -386,7 +381,7 @@ describe('SingleStatCtrl', () => {
ctx.setup(() => { ctx.setup(() => {
ctx.input = tableData; ctx.input = tableData;
ctx.input[0].rows[0] = [1492759673649, 'ignore1', 10, 'ignore2']; 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.tableColumn = 'mean';
ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }]; ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }];
}); });
+5 -4
View File
@@ -6,12 +6,11 @@ import { transformDataToTable } from './transformers';
import { tablePanelEditor } from './editor'; import { tablePanelEditor } from './editor';
import { columnOptionsTab } from './column_options'; import { columnOptionsTab } from './column_options';
import { TableRenderer } from './renderer'; 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 { TemplateSrv } from 'app/features/templating/template_srv';
import { PanelEvents } from '@grafana/data';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
class TablePanelCtrl extends MetricsPanelCtrl { export class TablePanelCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html'; static templateUrl = 'module.html';
pageIndex: number; 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();
+1 -1
View File
@@ -53,7 +53,7 @@ $panel-header-no-title-zindex: 1;
} }
.panel-menu-container { .panel-menu-container {
width: 1px; width: 0px;
height: 19px; height: 19px;
display: inline-block; display: inline-block;
} }
+1 -1
View File
@@ -63,7 +63,7 @@
} }
td { td {
padding: 0.45em 0 0.45em 1.1em; padding: 0.45em 1.1em;
border-bottom: 2px solid $body-bg; border-bottom: 2px solid $body-bg;
border-right: 2px solid $body-bg; border-right: 2px solid $body-bg;
+8 -1
View File
@@ -28,7 +28,15 @@
border: 0; border: 0;
} }
.panel-alert-icon {
display: none;
}
.panel-has-alert { .panel-has-alert {
.panel-alert-icon {
display: inline-block;
}
.panel-alert-icon:before { .panel-alert-icon:before {
content: '\e611'; content: '\e611';
position: relative; position: relative;
@@ -40,7 +48,6 @@
.panel-alert-state { .panel-alert-state {
&--alerting { &--alerting {
box-shadow: 0 0 10px rgba($critical, 0.5); box-shadow: 0 0 10px rgba($critical, 0.5);
position: relative;
.panel-alert-icon:before { .panel-alert-icon:before {
color: $critical; color: $critical;