Inspector: move Panel JSON and query inspector to the inspector (#23354)

* move Panel JSON to inspector

* move Panel JSON to inspector

* update test

* use stats display options

* move query inspector to inspector

* open inspector from the queries section

* subscribe to results

* subscribe to results

* open the right tab

* apply review feedback

* update menus (inspect tabs)

* Dashboard: extend dashnav to add custom content (#23433)

* Dashlist: Fixed dashlist broken in edit mode (#23426)

* Chore: Fix bunch of strict null error to fix master CI (#23443)

* Fix bunch of null error

* Fix failing test

* Another test fix

* Docs: Add SQL region annotation examples (#23268)

Add region annotation examples for SQL data sources in docs.

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>

* Docs: Update contributing doc to install node@12. (#23450)

* NewPanelEdit: Minor style and description tweaks, AND PanelQueryRunner & autoMinMax (#23445)

* NewPanelEdit: Minor style and description tweaks

* Removed the worst snapshot of all time

* ReactTable: adds color text to field options (#23427)

* Feature: adds text color field config

* Refactor: created an extension point

* Refactor: uses HOC for extension instead

* Fix: fixes background styling from affecting cells without display.color

* Chore: export OptionsUIRegistryBuilder on grafana/data (#23444)

* export the ui registry

* add to utils index also

* DataLinks: Do not full page reload data links links (#23429)

* Templating: Fix global variable "__org.id" (#23362)

* Fixed global variable __org.id value

* correct orgId value

* reverted the change as variables moved to new file

* Chore: reduce null check errors to 788 (currently over 798) (#23449)

* Fixed ts errors so build will succeed

* Update packages/grafana-data/src/types/graph.ts

Co-Authored-By: Ryan McKinley <ryantxu@gmail.com>

* Feedback from code review

* Leaving out trivial typing's

* Fix error with color being undefined now.

* fix test with timezone issue

* Fixed test

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>

* Cloudwatch: prefer webIdentity over EC2 role (#23452)

* Plugins: add a signature status flag (#23420)

* Progress

* fixed button

* Final touches

* now works from edit mode

* fix layout

* show raw objects

* move query inspector buttons to the bottom

* update snapshot

* Updated design

* Made full page reload work

* Fixed minor style issue

* Updated

* More fixes

* Removed unused imports

* Updated

* Moved to data tab out to seperate component

* fixed ts issue

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
Co-authored-by: Alexandre de Verteuil <alexandre@grafana.com>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
Co-authored-by: Cyril Tovena <cyril.tovena@gmail.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: Vikky Omkar <vikkyomkar@gmail.com>
Co-authored-by: Stephanie Closson <srclosson@gmail.com>
Co-authored-by: Dário Nascimento <dfrnascimento@gmail.com>
This commit is contained in:
Ryan McKinley
2020-04-15 16:51:51 +02:00
committed by GitHub
co-authored by Marcus Efraimsson Torkel Ödegaard Agnès Toulet Andrej Ocenas Alexandre de Verteuil Cyril Tovena Hugo Häggmark Vikky Omkar Stephanie Closson Dário Nascimento
parent cff70b6648
commit 17059489d8
20 changed files with 651 additions and 347 deletions
@@ -9,7 +9,7 @@ import { stylesFactory, useTheme } from '../../themes';
export interface Props {
children: ReactNode;
/** Title shown at the top of the drawer */
title?: (() => JSX.Element) | string;
title?: JSX.Element | string;
/** Should the Drawer be closable by clicking on the mask */
closeOnMaskClick?: boolean;
/** Render the drawer inside a container on the page */
@@ -98,7 +98,7 @@ export const Drawer: FC<Props> = ({
</div>
</div>
)}
{typeof title === 'function' && title()}
{typeof title !== 'string' && title}
<div className={drawerStyles.content}>
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>}
</div>
@@ -91,7 +91,7 @@ const renderForm = (defaultValues?: Partial<FormDTO>) => (
</Field>
<Field label="Textarea" invalid={!!errors.text} error="Text is required">
<TextArea name="text" placeholder="Long text" size="md" ref={register({ required: true })} />
<TextArea name="text" placeholder="Long text" ref={register({ required: true })} />
</Field>
<Field label="Checkbox" invalid={!!errors.checkbox} error="We need your consent">
@@ -1,7 +1,7 @@
import React from 'react';
import { TextArea } from './TextArea';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { boolean, number, select, text } from '@storybook/addon-knobs';
import { boolean, number, text } from '@storybook/addon-knobs';
import mdx from './TextArea.mdx';
export default {
@@ -25,8 +25,6 @@ export const simple = () => {
// ---
const placeholder = text('Placeholder', 'This is just a placeholder', VISUAL_GROUP);
const cols = number('Cols', 30, { range: true, min: 5, max: 50, step: 5 }, VISUAL_GROUP);
const size = select('Size', ['sm', 'md', 'lg', 'auto'], undefined, VISUAL_GROUP);
const CONTAINER_GROUP = 'Container options';
// ---
const containerWidth = number(
@@ -43,7 +41,7 @@ export const simple = () => {
return (
<div style={{ width: containerWidth }}>
<TextArea invalid={invalid} placeholder={placeholder} cols={cols} disabled={disabled} size={size} />
<TextArea invalid={invalid} placeholder={placeholder} cols={cols} disabled={disabled} />
</div>
);
};
@@ -2,28 +2,19 @@ import React, { HTMLProps } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { stylesFactory, useTheme } from '../../themes';
import { getFocusStyle, inputSizes, sharedInputStyle } from '../Forms/commonStyles';
import { FormInputSize } from '../Forms/types';
import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles';
export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
/** Show an invalid state around the input */
invalid?: boolean;
/** Choose a predefined size */
size?: FormInputSize;
}
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(
({ invalid, size = 'auto', className, ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(({ invalid, className, ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return (
<div className={inputSizes()[size]}>
<textarea {...props} className={cx(styles.textarea, className)} ref={ref} />
</div>
);
}
);
return <textarea {...props} className={cx(styles.textarea, className)} ref={ref} />;
});
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
return {
-1
View File
@@ -1,4 +1,3 @@
import './json_editor_ctrl';
import './invited_ctrl';
import './signup_ctrl';
import './reset_password_ctrl';
@@ -1,20 +0,0 @@
import angular from 'angular';
import coreModule from '../core_module';
export class JsonEditorCtrl {
/** @ngInject */
constructor($scope: any) {
$scope.json = angular.toJson($scope.model.object, true);
$scope.canUpdate = $scope.model.updateHandler !== void 0 && $scope.model.canUpdate;
$scope.canCopy = $scope.model.enableCopy;
$scope.update = () => {
const newObject = angular.fromJson($scope.json);
$scope.model.updateHandler(newObject, $scope.model.object);
};
$scope.getContentForClipboard = () => $scope.json;
}
}
coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);
+14 -8
View File
@@ -12,7 +12,6 @@ import 'mousetrap-global-bind';
import { ContextSrv } from './context_srv';
import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { getLocationSrv } from '@grafana/runtime';
import { DashboardModel } from '../../features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
@@ -125,6 +124,13 @@ export class KeybindingSrv {
return;
}
if (search.inspect) {
delete search.inspect;
delete search.inspectTab;
this.$location.search(search);
return;
}
if (search.editPanel) {
delete search.editPanel;
delete search.tab;
@@ -226,6 +232,13 @@ export class KeybindingSrv {
}
});
this.bind('i', () => {
if (dashboard.meta.focusPanelId) {
const search = _.extend(this.$location.search(), { inspect: dashboard.meta.focusPanelId });
this.$location.search(search);
}
});
// jump to explore if permissions allow
if (this.contextSrv.hasAccessToExplore()) {
this.bind('x', async () => {
@@ -279,13 +292,6 @@ export class KeybindingSrv {
}
});
// inspect panel
this.bind('p i', () => {
if (dashboard.meta.focusPanelId) {
getLocationSrv().update({ partial: true, query: { inspect: dashboard.meta.focusPanelId } });
}
});
// toggle panel legend
this.bind('p l', () => {
if (dashboard.meta.focusPanelId) {
@@ -0,0 +1,95 @@
import React, { PureComponent } from 'react';
import { DataFrame, applyFieldOverrides, toCSV, SelectableValue } from '@grafana/data';
import { Button, Select, Icon, Table } from '@grafana/ui';
import { getPanelInspectorStyles } from './styles';
import { config } from 'app/core/config';
import AutoSizer from 'react-virtualized-auto-sizer';
import { saveAs } from 'file-saver';
interface Props {
data: DataFrame[];
dataFrameIndex: number;
isLoading: boolean;
onSelectedFrameChanged: (item: SelectableValue<number>) => void;
}
export class InspectDataTab extends PureComponent<Props> {
constructor(props: Props) {
super(props);
}
exportCsv = (dataFrame: DataFrame) => {
const dataFrameCsv = toCSV([dataFrame]);
const blob = new Blob([dataFrameCsv], {
type: 'application/csv;charset=utf-8',
});
saveAs(blob, dataFrame.name + '-' + new Date().getUTCDate() + '.csv');
};
render() {
const { data, dataFrameIndex, isLoading, onSelectedFrameChanged } = this.props;
const styles = getPanelInspectorStyles();
if (isLoading) {
return (
<div>
Loading <Icon name="fa fa-spinner" className="fa-spin" size="lg" />
</div>
);
}
if (!data || !data.length) {
return <div>No Data</div>;
}
const choices = data.map((frame, index) => {
return {
value: index,
label: `${frame.name} (${index})`,
};
});
const processed = applyFieldOverrides({
data,
theme: config.theme,
fieldConfig: { defaults: {}, overrides: [] },
replaceVariables: (value: string) => {
return value;
},
});
return (
<div className={styles.dataTabContent}>
<div className={styles.toolbar}>
{choices.length > 1 && (
<div className={styles.dataFrameSelect}>
<Select options={choices} value={dataFrameIndex} onChange={onSelectedFrameChanged} />
</div>
)}
<div className={styles.downloadCsv}>
<Button variant="primary" onClick={() => this.exportCsv(processed[dataFrameIndex])}>
Download CSV
</Button>
</div>
</div>
<div style={{ flexGrow: 1 }}>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
return null;
}
return (
<div style={{ width, height }}>
<Table width={width} height={height} data={processed[dataFrameIndex]} />
</div>
);
}}
</AutoSizer>
</div>
</div>
);
}
}
@@ -32,11 +32,12 @@ export const InspectHeader: FC<Props> = ({
return (
<div className={styles.header}>
<div className={styles.actions}>
<IconButton name="angle-left" size="xl" onClick={onToggleExpand} surface="header" />
{!isExpanded && <IconButton name="angle-left" size="xl" onClick={onToggleExpand} surface="header" />}
{isExpanded && <IconButton name="angle-right" size="xl" onClick={onToggleExpand} surface="header" />}
<IconButton name="times" size="xl" onClick={onClose} surface="header" />
</div>
<div className={styles.titleWrapper}>
<h3>{panel.title}</h3>
<h3>{panel.title || 'Panel inspect'}</h3>
<div className="muted">{formatStats(panelData)}</div>
</div>
<TabsBar className={styles.tabsBar}>
@@ -0,0 +1,178 @@
import React, { PureComponent } from 'react';
import { chain } from 'lodash';
import { PanelData, SelectableValue, AppEvents } from '@grafana/data';
import { TextArea, Button, Select, ClipboardButton, JSONFormatter, Field } from '@grafana/ui';
import { appEvents } from 'app/core/core';
import { PanelModel, DashboardModel } from '../../state';
import { getPanelInspectorStyles } from './styles';
enum ShowContent {
PanelJSON = 'panel',
PanelData = 'data',
DataStructure = 'structure',
}
const options: Array<SelectableValue<ShowContent>> = [
{
label: 'Panel JSON',
description: 'The model saved in the dashboard JSON that configures how everythign works.',
value: ShowContent.PanelJSON,
},
{
label: 'Panel data',
description: 'The raw model passed to the panel visualization',
value: ShowContent.PanelData,
},
{
label: 'DataFrame structure',
description: 'Response info without any values',
value: ShowContent.DataStructure,
},
];
interface Props {
dashboard: DashboardModel;
panel: PanelModel;
data: PanelData;
onClose: () => void;
}
interface State {
show: ShowContent;
text: string;
}
export class InspectJSONTab extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
show: ShowContent.PanelJSON,
text: getSaveModelJSON(props.panel),
};
}
onSelectChanged = (item: SelectableValue<ShowContent>) => {
let text = '';
if (item.value === ShowContent.PanelJSON) {
text = getSaveModelJSON(this.props.panel);
}
this.setState({ text, show: item.value });
};
onTextChanged = (e: React.FormEvent<HTMLTextAreaElement>) => {
const text = e.currentTarget.value;
this.setState({ text });
};
getJSONObject = (show: ShowContent): any => {
if (show === ShowContent.PanelData) {
return this.props.data;
}
if (show === ShowContent.DataStructure) {
const series = this.props.data?.series;
if (!series) {
return { note: 'Missing Response Data' };
}
return this.props.data.series.map(frame => {
const fields = frame.fields.map(field => {
return chain(field)
.omit('values')
.omit('calcs')
.omit('display')
.value();
});
return {
...frame,
fields,
};
});
}
if (show === ShowContent.PanelJSON) {
return this.props.panel.getSaveModel();
}
return { note: 'Unknown Object', show };
};
getClipboardText = () => {
const { show } = this.state;
const obj = this.getJSONObject(show);
return JSON.stringify(obj, null, 2);
};
onClipboardCopied = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
alert('TODO... the notice is behind the inspector!');
};
onApplyPanelModel = () => {
const { panel, dashboard, onClose } = this.props;
try {
if (!dashboard.meta.canEdit) {
appEvents.emit(AppEvents.alertError, ['Unable to apply']);
} else {
const updates = JSON.parse(this.state.text);
panel.restoreModel(updates);
panel.refresh();
appEvents.emit(AppEvents.alertSuccess, ['Panel model updated']);
}
} catch (err) {
console.log('Error applyign updates', err);
appEvents.emit(AppEvents.alertError, ['Invalid JSON text']);
}
onClose();
};
renderPanelJSON(styles: any) {
return (
<TextArea spellCheck={false} value={this.state.text} onChange={this.onTextChanged} className={styles.editor} />
);
}
render() {
const { dashboard } = this.props;
const { show } = this.state;
const selected = options.find(v => v.value === show);
const isPanelJSON = show === ShowContent.PanelJSON;
const canEdit = dashboard.meta.canEdit;
const styles = getPanelInspectorStyles();
return (
<>
<div className={styles.toolbar}>
<Field label="Select source" className="flex-grow-1">
<Select options={options} value={selected} onChange={this.onSelectChanged} />
</Field>
<ClipboardButton
variant="secondary"
className={styles.toolbarItem}
getText={this.getClipboardText}
onClipboardCopy={this.onClipboardCopied}
>
Copy to clipboard
</ClipboardButton>
{isPanelJSON && canEdit && (
<Button className={styles.toolbarItem} onClick={this.onApplyPanelModel}>
Apply
</Button>
)}
</div>
<div className={styles.content}>
{isPanelJSON ? (
this.renderPanelJSON(styles)
) : (
<div className={styles.viewer}>
<JSONFormatter json={this.getJSONObject(show)} />
</div>
)}
</div>
</>
);
}
}
function getSaveModelJSON(panel: PanelModel): string {
return JSON.stringify(panel.getSaveModel(), null, 2);
}
@@ -1,99 +1,122 @@
import React, { PureComponent } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { saveAs } from 'file-saver';
import { css } from 'emotion';
import { Unsubscribable } from 'rxjs';
import { connect, MapStateToProps } from 'react-redux';
import { InspectHeader } from './InspectHeader';
import { InspectJSONTab } from './InspectJSONTab';
import { QueryInspector } from './QueryInspector';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import {
JSONFormatter,
Drawer,
LegacyForms,
Table,
TabContent,
stylesFactory,
CustomScrollbar,
Button,
} from '@grafana/ui';
const { Select } = LegacyForms;
import { JSONFormatter, Drawer, TabContent, CustomScrollbar } from '@grafana/ui';
import { getLocationSrv, getDataSourceSrv } from '@grafana/runtime';
import {
DataFrame,
DataSourceApi,
SelectableValue,
applyFieldOverrides,
toCSV,
getDisplayProcessor,
DataQueryError,
PanelData,
getValueFormat,
FieldType,
formattedValueToString,
QueryResultMetaStat,
LoadingState,
PanelPlugin,
} from '@grafana/data';
import { config } from 'app/core/config';
import { getPanelInspectorStyles } from './styles';
import { StoreState } from 'app/types';
import { InspectDataTab } from './InspectDataTab';
interface Props {
interface OwnProps {
dashboard: DashboardModel;
panel: PanelModel;
selectedTab: InspectTab;
defaultTab: InspectTab;
}
export interface ConnectedProps {
plugin?: PanelPlugin | null;
}
export type Props = OwnProps & ConnectedProps;
export enum InspectTab {
Data = 'data',
Request = 'request',
Issue = 'issue',
Meta = 'meta', // When result metadata exists
Error = 'error',
Stats = 'stats',
PanelJson = 'paneljson',
JSON = 'json',
Query = 'query',
}
interface State {
isLoading: boolean;
// The last raw response
last: PanelData;
// Data from the last response
data: DataFrame[];
// The selected data frame
selected: number;
selectedDataFrame: number;
// The Selected Tab
tab: InspectTab;
currentTab: InspectTab;
// If the datasource supports custom metadata
metaDS?: DataSourceApi;
// drawer width
drawerWidth: string;
}
export class PanelInspector extends PureComponent<Props, State> {
export class PanelInspectorUnconnected extends PureComponent<Props, State> {
querySubscription?: Unsubscribable;
constructor(props: Props) {
super(props);
this.state = {
isLoading: true,
last: {} as PanelData,
data: [],
selected: 0,
tab: props.selectedTab || InspectTab.Data,
selectedDataFrame: 0,
currentTab: props.defaultTab ?? InspectTab.Data,
drawerWidth: '50%',
};
}
async componentDidMount() {
const { panel } = this.props;
componentDidMount() {
const { plugin } = this.props;
if (!panel) {
this.onDismiss(); // Try to close the component
return;
if (plugin) {
this.init();
}
}
const lastResult = panel.getQueryRunner().getLastResult();
if (!lastResult) {
this.onDismiss(); // Usually opened from refresh?
return;
componentDidUpdate(prevProps: Props) {
if (prevProps.plugin !== this.props.plugin) {
this.init();
}
}
/**
* This init process where we do not have a plugin to start with is to handle full page reloads with inspect url parameter
* When this inspect drawer loads the plugin is not yet loaded.
*/
init() {
const { plugin, panel } = this.props;
if (plugin && !plugin.meta.skipDataQuery) {
this.querySubscription = panel
.getQueryRunner()
.getData()
.subscribe({
next: data => this.onUpdateData(data),
});
}
}
componentWillUnmount() {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
}
}
async onUpdateData(lastResult: PanelData) {
let metaDS: DataSourceApi;
const data = lastResult.series;
const error = lastResult.error;
@@ -117,16 +140,17 @@ export class PanelInspector extends PureComponent<Props, State> {
// Set last result, but no metadata inspector
this.setState(prevState => ({
isLoading: lastResult.state === LoadingState.Loading,
last: lastResult,
data,
metaDS,
tab: error ? InspectTab.Error : prevState.tab,
currentTab: error ? InspectTab.Error : prevState.currentTab,
}));
}
onDismiss = () => {
onClose = () => {
getLocationSrv().update({
query: { inspect: null, tab: null },
query: { inspect: null, inspectTab: null },
partial: true,
});
};
@@ -138,21 +162,11 @@ export class PanelInspector extends PureComponent<Props, State> {
};
onSelectTab = (item: SelectableValue<InspectTab>) => {
this.setState({ tab: item.value || InspectTab.Data });
this.setState({ currentTab: item.value || InspectTab.Data });
};
onSelectedFrameChanged = (item: SelectableValue<number>) => {
this.setState({ selected: item.value || 0 });
};
exportCsv = (dataFrame: DataFrame) => {
const dataFrameCsv = toCSV([dataFrame]);
const blob = new Blob([dataFrameCsv], {
type: 'application/csv;charset=utf-8',
});
saveAs(blob, dataFrame.name + '-' + new Date().getUTCDate() + '.csv');
this.setState({ selectedDataFrame: item.value || 0 });
};
renderMetadataInspector() {
@@ -164,63 +178,15 @@ export class PanelInspector extends PureComponent<Props, State> {
}
renderDataTab() {
const { data, selected } = this.state;
const styles = getStyles();
if (!data || !data.length) {
return <div>No Data</div>;
}
const choices = data.map((frame, index) => {
return {
value: index,
label: `${frame.name} (${index})`,
};
});
const processed = applyFieldOverrides({
data,
theme: config.theme,
fieldConfig: { defaults: {}, overrides: [] },
replaceVariables: (value: string) => {
return value;
},
});
const { last, isLoading, selectedDataFrame } = this.state;
return (
<div className={styles.dataTabContent}>
<div className={styles.toolbar}>
{choices.length > 1 && (
<div className={styles.dataFrameSelect}>
<Select
options={choices}
value={choices.find(t => t.value === selected)}
onChange={this.onSelectedFrameChanged}
/>
</div>
)}
<div className={styles.downloadCsv}>
<Button variant="primary" onClick={() => this.exportCsv(processed[selected])}>
Download CSV
</Button>
</div>
</div>
<div style={{ flexGrow: 1 }}>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
return null;
}
return (
<div style={{ width, height }}>
<Table width={width} height={height} data={processed[selected]} />
</div>
);
}}
</AutoSizer>
</div>
</div>
<InspectDataTab
data={last.series}
isLoading={isLoading}
dataFrameIndex={selectedDataFrame}
onSelectedFrameChanged={this.onSelectedFrameChanged}
/>
);
}
@@ -239,18 +205,6 @@ export class PanelInspector extends PureComponent<Props, State> {
return <div>{error.message}</div>;
}
renderRequestTab() {
return (
<CustomScrollbar>
<JSONFormatter json={this.state.last} open={2} />
</CustomScrollbar>
);
}
renderJsonModelTab() {
return <JSONFormatter json={this.props.panel.getSaveModel()} open={2} />;
}
renderStatsTab() {
const { last } = this.state;
const { request } = last;
@@ -285,12 +239,16 @@ export class PanelInspector extends PureComponent<Props, State> {
return (
<>
{this.renderStatsTable('Stats', stats)}
{dataStats.length && this.renderStatsTable('Data source stats', dataStats)}
{this.renderStatsTable('Data source stats', dataStats)}
</>
);
}
renderStatsTable(name: string, stats: QueryResultMetaStat[]) {
if (!stats || !stats.length) {
return null;
}
return (
<div style={{ paddingBottom: '16px' }}>
<div className="section-heading">{name}</div>
@@ -300,7 +258,7 @@ export class PanelInspector extends PureComponent<Props, State> {
return (
<tr key={`${stat.title}-${index}`}>
<td>{stat.title}</td>
<td style={{ textAlign: 'right' }}>{formatStat(stat.value, stat.unit)}</td>
<td style={{ textAlign: 'right' }}>{formatStat(stat)}</td>
</tr>
);
})}
@@ -310,93 +268,109 @@ export class PanelInspector extends PureComponent<Props, State> {
);
}
drawerHeader = () => {
const { tab, last } = this.state;
drawerHeader(tabs: Array<{ label: string; value: InspectTab }>, activeTab: InspectTab) {
const { panel } = this.props;
const { last } = this.state;
return (
<InspectHeader
tabs={tabs}
tab={activeTab}
panelData={last}
onSelectTab={this.onSelectTab}
onClose={this.onClose}
panel={panel}
onToggleExpand={this.onToggleExpand}
isExpanded={this.state.drawerWidth === '100%'}
/>
);
}
getTabs() {
const { dashboard, plugin } = this.props;
const { last } = this.state;
const error = last?.error;
const tabs = [];
if (last && last?.series?.length > 0) {
if (plugin && !plugin.meta.skipDataQuery) {
tabs.push({ label: 'Data', value: InspectTab.Data });
tabs.push({ label: 'Stats', value: InspectTab.Stats });
}
tabs.push({ label: 'Stats', value: InspectTab.Stats });
tabs.push({ label: 'Request', value: InspectTab.Request });
tabs.push({ label: 'Panel JSON', value: InspectTab.PanelJson });
if (this.state.metaDS) {
tabs.push({ label: 'Meta Data', value: InspectTab.Meta });
}
tabs.push({ label: 'JSON', value: InspectTab.JSON });
if (error && error.message) {
tabs.push({ label: 'Error', value: InspectTab.Error });
}
return (
<InspectHeader
tabs={tabs}
tab={tab}
panelData={last}
onSelectTab={this.onSelectTab}
onClose={this.onDismiss}
panel={this.props.panel}
onToggleExpand={this.onToggleExpand}
isExpanded={this.state.drawerWidth === '100%'}
/>
);
};
if (dashboard.meta.canEdit) {
tabs.push({ label: 'Query', value: InspectTab.Query });
}
return tabs;
}
render() {
const { last, tab, drawerWidth } = this.state;
const styles = getStyles();
const { panel, dashboard, plugin } = this.props;
const { currentTab } = this.state;
if (!plugin) {
return null;
}
const { last, drawerWidth } = this.state;
const styles = getPanelInspectorStyles();
const error = last?.error;
const tabs = this.getTabs();
// Validate that the active tab is actually valid and allowed
let activeTab = currentTab;
if (!tabs.find(item => item.value === currentTab)) {
activeTab = InspectTab.JSON;
}
return (
<Drawer title={this.drawerHeader} width={drawerWidth} onClose={this.onDismiss}>
<TabContent className={styles.tabContent}>
{tab === InspectTab.Data && this.renderDataTab()}
<CustomScrollbar autoHeightMin="100%">
{tab === InspectTab.Meta && this.renderMetadataInspector()}
{tab === InspectTab.Request && this.renderRequestTab()}
{tab === InspectTab.Error && this.renderErrorTab(error)}
{tab === InspectTab.Stats && this.renderStatsTab()}
{tab === InspectTab.PanelJson && this.renderJsonModelTab()}
</CustomScrollbar>
</TabContent>
<Drawer title={this.drawerHeader(tabs, activeTab)} width={drawerWidth} onClose={this.onClose}>
{activeTab === InspectTab.Data && this.renderDataTab()}
<CustomScrollbar autoHeightMin="100%">
<TabContent className={styles.tabContent}>
{activeTab === InspectTab.Meta && this.renderMetadataInspector()}
{activeTab === InspectTab.JSON && (
<InspectJSONTab panel={panel} dashboard={dashboard} data={last} onClose={this.onClose} />
)}
{activeTab === InspectTab.Error && this.renderErrorTab(error)}
{activeTab === InspectTab.Stats && this.renderStatsTab()}
{activeTab === InspectTab.Query && <QueryInspector panel={panel} />}
</TabContent>
</CustomScrollbar>
</Drawer>
);
}
}
function formatStat(value: any, unit?: string): string {
if (unit) {
return formattedValueToString(getValueFormat(unit)(value));
} else {
return value;
}
function formatStat(stat: QueryResultMetaStat): string {
const display = getDisplayProcessor({
field: {
type: FieldType.number,
config: stat,
},
theme: config.theme,
});
return formattedValueToString(display(stat.value));
}
const getStyles = stylesFactory(() => {
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
const panelState = state.dashboard.panels[props.panel.id];
if (!panelState) {
return { plugin: null };
}
return {
toolbar: css`
display: flex;
margin: 8px 0;
justify-content: flex-end;
align-items: center;
`,
dataFrameSelect: css`
flex-grow: 2;
`,
downloadCsv: css`
margin-left: 16px;
`,
tabContent: css`
height: calc(100% - 32px);
`,
dataTabContent: css`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`,
plugin: panelState.plugin,
};
});
};
export const PanelInspector = connect(mapStateToProps)(PanelInspectorUnconnected);
@@ -1,9 +1,11 @@
import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { JSONFormatter, LoadingPlaceholder, Icon } from '@grafana/ui';
import { JSONFormatter, LoadingPlaceholder, Button } from '@grafana/ui';
import { CoreEvents } from 'app/types';
import { AppEvents, PanelEvents } from '@grafana/data';
import { PanelModel } from 'app/features/dashboard/state';
import { getPanelInspectorStyles } from './styles';
interface DsQuery {
isLoading: boolean;
@@ -11,7 +13,7 @@ interface DsQuery {
}
interface Props {
panel: any;
panel: PanelModel;
}
interface State {
@@ -39,15 +41,15 @@ export class QueryInspector extends PureComponent<Props, State> {
}
componentDidMount() {
const { panel } = this.props;
appEvents.on(CoreEvents.dsRequestResponse, this.onDataSourceResponse);
appEvents.on(CoreEvents.dsRequestError, this.onRequestError);
panel.events.on(PanelEvents.refresh, this.onPanelRefresh);
panel.refresh();
this.props.panel.events.on(PanelEvents.refresh, this.onPanelRefresh);
}
onIssueNewQuery = () => {
this.props.panel.refresh();
};
componentWillUnmount() {
const { panel } = this.props;
@@ -177,46 +179,58 @@ export class QueryInspector extends PureComponent<Props, State> {
}));
};
renderExpandCollapse = () => {
const { allNodesExpanded } = this.state;
const collapse = (
<>
<Icon name="minus-circle" /> Collapse All
</>
);
const expand = (
<>
<Icon name="plus-circle" /> Expand All
</>
);
return allNodesExpanded ? collapse : expand;
};
render() {
const { allNodesExpanded } = this.state;
const { response, isLoading } = this.state.dsQuery;
const openNodes = this.getNrOfOpenNodes();
if (isLoading) {
return <LoadingPlaceholder text="Loading query inspector..." />;
}
const styles = getPanelInspectorStyles();
const haveData = Object.keys(response).length > 0;
return (
<>
<div className="pull-right">
<button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleExpand}>
{this.renderExpandCollapse()}
</button>
<CopyToClipboard
className="btn btn-transparent btn-p-x-0"
text={this.getTextForClipboard}
onSuccess={this.onClipboardSuccess}
>
<Icon name="copy" /> Copy to Clipboard
</CopyToClipboard>
<div>
<h3 className="section-heading">Query inspector</h3>
<p className="small muted">
Query inspector allows you to view raw request and response. To collect this data Grafana needs to issue a
new query. Hit refresh button below to trigger a new query.
</p>
</div>
<div className={styles.toolbar}>
<Button icon="sync" onClick={this.onIssueNewQuery}>
Refresh
</Button>
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
{haveData && allNodesExpanded && (
<Button icon="minus" variant="secondary" className={styles.toolbarItem} onClick={this.onToggleExpand}>
Collapse all
</Button>
)}
{haveData && !allNodesExpanded && (
<Button icon="plus" variant="secondary" className={styles.toolbarItem} onClick={this.onToggleExpand}>
Expand all
</Button>
)}
{haveData && (
<CopyToClipboard
text={this.getTextForClipboard}
onSuccess={this.onClipboardSuccess}
elType="div"
className={styles.toolbarItem}
>
<Button icon="copy" variant="secondary">
Copy to clipboard
</Button>
</CopyToClipboard>
)}
</div>
<div className={styles.contentQueryInspector}>
{isLoading && <LoadingPlaceholder text="Loading query inspector..." />}
{!isLoading && haveData && (
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
)}
{!isLoading && !haveData && <p className="muted">No request & response collected yet. Hit refresh button</p>}
</div>
</>
);
}
@@ -0,0 +1,58 @@
import { css } from 'emotion';
import { config } from 'app/core/config';
import { stylesFactory } from '@grafana/ui';
export const getPanelInspectorStyles = stylesFactory(() => {
return {
wrap: css`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
flex: 1 1 0;
`,
toolbar: css`
display: flex;
width: 100%;
flex-grow: 0;
align-items: center;
justify-content: flex-end;
`,
toolbarItem: css`
margin-left: ${config.theme.spacing.md};
`,
content: css`
flex-grow: 1;
padding-bottom: 16px;
`,
contentQueryInspector: css`
flex-grow: 1;
padding: ${config.theme.spacing.md} 0;
`,
editor: css`
font-family: monospace;
height: 100%;
flex-grow: 1;
`,
viewer: css`
overflow: scroll;
`,
dataFrameSelect: css`
flex-grow: 2;
`,
downloadCsv: css`
margin-left: 16px;
`,
tabContent: css`
height: 100%;
display: flex;
flex-direction: column;
`,
dataTabContent: css`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`,
};
});
@@ -236,6 +236,22 @@ export class DashboardPage extends PureComponent<Props, State> {
);
}
getInspectPanel() {
const { dashboard, inspectPanelId } = this.props;
if (!dashboard || !inspectPanelId) {
return null;
}
const inspectPanel = dashboard.getPanelById(parseInt(inspectPanelId, 10));
// cannot inspect panels plugin is not already loaded
if (!inspectPanel) {
return null;
}
return inspectPanel;
}
render() {
const {
dashboard,
@@ -243,7 +259,6 @@ export class DashboardPage extends PureComponent<Props, State> {
$injector,
isInitSlow,
initError,
inspectPanelId,
inspectTab,
isNewEditorOpen,
updateLocation,
@@ -264,11 +279,9 @@ export class DashboardPage extends PureComponent<Props, State> {
'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
});
// Find the panel to inspect
const inspectPanel = inspectPanelId ? dashboard.getPanelById(parseInt(inspectPanelId, 10)) : null;
// Only trigger render when the scroll has moved by 25
const approximateScrollTop = Math.round(scrollTop / 25) * 25;
const inspectPanel = this.getInspectPanel();
return (
<div>
@@ -297,7 +310,7 @@ export class DashboardPage extends PureComponent<Props, State> {
</CustomScrollbar>
</div>
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} selectedTab={inspectTab} />}
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} defaultTab={inspectTab} />}
{editPanel && <PanelEditor dashboard={dashboard} sourcePanel={editPanel} />}
{editview && <DashboardSettings dashboard={dashboard} updateLocation={updateLocation} />}
</div>
@@ -319,7 +332,7 @@ export const mapStateToProps = (state: StoreState) => ({
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
dashboard: state.dashboard.getModel() as DashboardModel,
inspectTab: state.location.query.tab,
inspectTab: state.location.query.inspectTab,
isNewEditorOpen: state.panelEditorNew.isOpen,
});
@@ -4,9 +4,9 @@ import _ from 'lodash';
// Components
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { QueryInspector } from './QueryInspector';
import { QueryOptions } from './QueryOptions';
import { PanelOptionsGroup } from '@grafana/ui';
import { getLocationSrv } from '@grafana/runtime';
import { QueryEditorRows } from './QueryEditorRows';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -119,9 +119,12 @@ export class QueriesTab extends PureComponent<Props, State> {
});
};
renderQueryInspector = () => {
openQueryInspector = () => {
const { panel } = this.props;
return <QueryInspector panel={panel} />;
getLocationSrv().update({
query: { inspect: panel.id, inspectTab: 'query' },
partial: true,
});
};
renderHelp = () => {
@@ -165,7 +168,7 @@ export class QueriesTab extends PureComponent<Props, State> {
<div className="flex-grow-1" />
{showAddButton && (
<button className="btn navbar-button" onClick={this.onAddQueryClick}>
Add Query
Add query
</button>
)}
{isAddingMixed && this.renderMixedPicker()}
@@ -244,8 +247,8 @@ export class QueriesTab extends PureComponent<Props, State> {
render() {
const { scrollTop } = this.state;
const queryInspector: EditorToolbarView = {
title: 'Query Inspector',
render: this.renderQueryInspector,
title: 'Query inspector',
onClick: this.openQueryInspector,
};
const dsHelp: EditorToolbarView = {
@@ -256,7 +259,7 @@ export class QueriesTab extends PureComponent<Props, State> {
return (
<EditorTabBody
heading="Query"
heading="Data source"
renderToolbar={this.renderToolbar}
toolbarItems={[queryInspector, dsHelp]}
setScrollTop={this.setScrollTop}
@@ -367,6 +367,10 @@ export class DashboardModel {
}
getPanelById(id: number): PanelModel {
if (this.panelInEdit && this.panelInEdit.id === id) {
return this.panelInEdit;
}
for (const panel of this.panels) {
if (panel.id === id) {
return panel;
@@ -30,8 +30,15 @@ describe('getPanelMenu', () => {
Object {
"iconClassName": "info-circle",
"onClick": [Function],
"shortcut": "p i",
"shortcut": "i",
"subMenu": Array [
Object {
"onClick": [Function],
"text": "Panel JSON",
},
],
"text": "Inspect",
"type": "submenu",
},
Object {
"iconClassName": "cube",
@@ -46,10 +53,6 @@ describe('getPanelMenu', () => {
"onClick": [Function],
"text": "Copy",
},
Object {
"onClick": [Function],
"text": "Panel JSON",
},
],
"text": "More...",
"type": "submenu",
@@ -2,7 +2,7 @@ import { updateLocation } from 'app/core/actions';
import { store } from 'app/store/store';
import { getDataSourceSrv, getLocationSrv, AngularComponent } from '@grafana/runtime';
import { PanelMenuItem } from '@grafana/data';
import { copyPanel, duplicatePanel, editPanelJson, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { copyPanel, duplicatePanel, removePanel, sharePanel } from 'app/features/dashboard/utils/panel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { contextSrv } from '../../../core/services/context_srv';
@@ -45,12 +45,14 @@ export function getPanelMenu(
sharePanel(dashboard, panel);
};
const onInspectPanel = (event: React.MouseEvent<any>) => {
const onInspectPanel = (tab?: string) => {
event.preventDefault();
getLocationSrv().update({
partial: true,
query: {
inspect: panel.id,
inspectTab: tab,
},
});
};
@@ -69,11 +71,6 @@ export function getPanelMenu(
copyPanel(panel);
};
const onEditPanelJson = (event: React.MouseEvent<any>) => {
event.preventDefault();
editPanelJson(dashboard, panel);
};
const onRemovePanel = (event: React.MouseEvent<any>) => {
event.preventDefault();
removePanel(dashboard, panel, true);
@@ -119,11 +116,35 @@ export function getPanelMenu(
});
}
const inspectMenu: PanelMenuItem[] = [];
// Only show these inspect actions for data plugins
if (panel.plugin && !panel.plugin.meta.skipDataQuery) {
inspectMenu.push({
text: 'Data',
onClick: (e: React.MouseEvent<any>) => onInspectPanel('data'),
});
if (dashboard.meta.canEdit) {
inspectMenu.push({
text: 'Query',
onClick: (e: React.MouseEvent<any>) => onInspectPanel('query'),
});
}
}
inspectMenu.push({
text: 'Panel JSON',
onClick: (e: React.MouseEvent<any>) => onInspectPanel('json'),
});
menu.push({
type: 'submenu',
text: 'Inspect',
iconClassName: 'info-circle',
onClick: onInspectPanel,
shortcut: 'p i',
onClick: (e: React.MouseEvent<any>) => onInspectPanel(),
shortcut: 'i',
subMenu: inspectMenu,
});
const subMenu: PanelMenuItem[] = [];
@@ -141,11 +162,6 @@ export function getPanelMenu(
});
}
subMenu.push({
text: 'Panel JSON',
onClick: onEditPanelJson,
});
// add old angular panel options
if (angularComponent) {
const scope = angularComponent.getScope();
+1 -33
View File
@@ -3,7 +3,7 @@ import store from 'app/core/store';
// Models
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel, panelRemoved, panelAdded } from 'app/features/dashboard/state/PanelModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { TimeRange, AppEvents } from '@grafana/data';
// Utils
@@ -51,38 +51,6 @@ export const copyPanel = (panel: PanelModel) => {
appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Open Add Panel to paste']);
};
const replacePanel = (dashboard: DashboardModel, newPanel: PanelModel, oldPanel: PanelModel) => {
const index = dashboard.panels.findIndex(panel => {
return panel.id === oldPanel.id;
});
const deletedPanel = dashboard.panels.splice(index, 1)[0];
dashboard.events.emit(panelRemoved, deletedPanel);
newPanel = new PanelModel(newPanel);
newPanel.id = oldPanel.id;
dashboard.panels.splice(index, 0, newPanel);
dashboard.sortPanelsByGridPos();
dashboard.events.emit(panelAdded, newPanel);
};
export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
const model = {
object: panel.getSaveModel(),
updateHandler: (newPanel: PanelModel, oldPanel: PanelModel) => {
replacePanel(dashboard, newPanel, oldPanel);
},
canUpdate: dashboard.meta.canEdit,
enableCopy: true,
};
appEvents.emit(CoreEvents.showModal, {
src: 'public/app/partials/edit_json.html',
model: model,
});
};
export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit(CoreEvents.showModalReact, {
component: ShareModal,
+4 -1
View File
@@ -21,15 +21,18 @@ p {
// Ex: 14px base font * 85% = about 12px
small {
font-size: 85%;
font-size: $font-size-sm;
}
strong {
font-weight: $font-weight-semi-bold;
}
em {
font-style: italic;
color: $headings-color;
}
cite {
font-style: normal;
}