diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index ff44805e35f..04b77a892c5 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -24,6 +24,7 @@ type DataSourcePlugin struct { Metrics bool `json:"metrics"` Alerting bool `json:"alerting"` Explore bool `json:"explore"` + Table bool `json:"tables"` Logs bool `json:"logs"` QueryOptions map[string]bool `json:"queryOptions,omitempty"` BuiltIn bool `json:"builtIn,omitempty"` diff --git a/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 00000000000..ea63de58b47 --- /dev/null +++ b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,67 @@ +import React, { PureComponent, ReactNode } from 'react'; +import ClipboardJS from 'clipboard'; + +interface Props { + text: () => string; + elType?: string; + onSuccess?: (evt: any) => void; + onError?: (evt: any) => void; + className?: string; + children?: ReactNode; +} + +export class CopyToClipboard extends PureComponent { + clipboardjs: any; + myRef: any; + + constructor(props) { + super(props); + this.myRef = React.createRef(); + } + + componentDidMount() { + const { text, onSuccess, onError } = this.props; + + this.clipboardjs = new ClipboardJS(this.myRef.current, { + text: text, + }); + + if (onSuccess) { + this.clipboardjs.on('success', evt => { + evt.clearSelection(); + onSuccess(evt); + }); + } + + if (onError) { + this.clipboardjs.on('error', evt => { + console.error('Action:', evt.action); + console.error('Trigger:', evt.trigger); + onError(evt); + }); + } + } + + componentWillUnmount() { + if (this.clipboardjs) { + this.clipboardjs.destroy(); + } + } + + getElementType = () => { + return this.props.elType || 'button'; + }; + + render() { + const { elType, text, children, onError, onSuccess, ...restProps } = this.props; + + return React.createElement( + this.getElementType(), + { + ref: this.myRef, + ...restProps, + }, + this.props.children + ); + } +} diff --git a/public/app/core/components/Form/Element.tsx b/public/app/core/components/Form/Element.tsx new file mode 100644 index 00000000000..997d7f0e717 --- /dev/null +++ b/public/app/core/components/Form/Element.tsx @@ -0,0 +1,43 @@ +import React, { PureComponent, ReactNode, ReactElement } from 'react'; +import { Label } from './Label'; +import { uniqueId } from 'lodash'; + +interface Props { + label?: ReactNode; + labelClassName?: string; + id?: string; + children: ReactElement; +} + +export class Element extends PureComponent { + elementId: string = this.props.id || uniqueId('form-element-'); + + get elementLabel() { + const { label, labelClassName } = this.props; + + if (label) { + return ( + + ); + } + + return null; + } + + get children() { + const { children } = this.props; + + return React.cloneElement(children, { id: this.elementId }); + } + + render() { + return ( +
+ {this.elementLabel} + {this.children} +
+ ); + } +} diff --git a/public/app/core/components/Form/Input.test.tsx b/public/app/core/components/Form/Input.test.tsx new file mode 100644 index 00000000000..9e903208e80 --- /dev/null +++ b/public/app/core/components/Form/Input.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { shallow } from 'enzyme'; +import { Input, EventsWithValidation } from './Input'; +import { ValidationEvents } from 'app/types'; + +const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars'; +const testBlurValidation: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: (value: string) => { + if (!value || value.length < 3) { + return true; + } + return false; + }, + errorMessage: TEST_ERROR_MESSAGE, + }, + ], +}; + +describe('Input', () => { + it('renders correctly', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should validate with error onBlur', () => { + const wrapper = shallow(); + const evt = { + persist: jest.fn, + target: { + value: 'I can not be more than 2 chars', + }, + }; + + wrapper.find('input').simulate('blur', evt); + expect(wrapper.state('error')).toBe(TEST_ERROR_MESSAGE); + }); + + it('should validate without error onBlur', () => { + const wrapper = shallow(); + const evt = { + persist: jest.fn, + target: { + value: 'Hi', + }, + }; + + wrapper.find('input').simulate('blur', evt); + expect(wrapper.state('error')).toBe(null); + }); +}); diff --git a/public/app/core/components/Form/Input.tsx b/public/app/core/components/Form/Input.tsx new file mode 100644 index 00000000000..6ba58b45e91 --- /dev/null +++ b/public/app/core/components/Form/Input.tsx @@ -0,0 +1,89 @@ +import React, { PureComponent } from 'react'; +import { ValidationEvents, ValidationRule } from 'app/types'; +import { validate, hasValidationEvent } from 'app/core/utils/validate'; + +export enum InputStatus { + Invalid = 'invalid', + Valid = 'valid', +} + +export enum InputTypes { + Text = 'text', + Number = 'number', + Password = 'password', + Email = 'email', +} + +export enum EventsWithValidation { + onBlur = 'onBlur', + onFocus = 'onFocus', + onChange = 'onChange', +} + +interface Props extends React.HTMLProps { + validationEvents?: ValidationEvents; + hideErrorMessage?: boolean; + + // Override event props and append status as argument + onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; + onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; + onChange?: (event: React.FormEvent, status?: InputStatus) => void; +} + +export class Input extends PureComponent { + state = { + error: null, + }; + + get status() { + return this.state.error ? InputStatus.Invalid : InputStatus.Valid; + } + + get isInvalid() { + return this.status === InputStatus.Invalid; + } + + validatorAsync = (validationRules: ValidationRule[]) => { + return evt => { + const errors = validate(evt.target.value, validationRules); + this.setState(prevState => { + return { + ...prevState, + error: errors ? errors[0] : null, + }; + }); + }; + }; + + populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { + const inputElementProps = { ...restProps }; + Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => { + if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) { + inputElementProps[eventName] = async evt => { + evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling + if (hasValidationEvent(eventName, validationEvents)) { + await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]); + } + if (restProps[eventName]) { + restProps[eventName].apply(null, [evt, this.status]); + } + }; + } + }); + return inputElementProps; + }; + + render() { + const { validationEvents, className, hideErrorMessage, ...restProps } = this.props; + const { error } = this.state; + const inputClassName = 'gf-form-input' + (this.isInvalid ? ' invalid' : ''); + const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); + + return ( +
+ + {error && !hideErrorMessage && {error}} +
+ ); + } +} diff --git a/public/app/core/components/Form/Label.tsx b/public/app/core/components/Form/Label.tsx new file mode 100644 index 00000000000..385a1b325be --- /dev/null +++ b/public/app/core/components/Form/Label.tsx @@ -0,0 +1,19 @@ +import React, { PureComponent, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + htmlFor?: string; + className?: string; +} + +export class Label extends PureComponent { + render() { + const { children, htmlFor, className } = this.props; + + return ( + + ); + } +} diff --git a/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap b/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap new file mode 100644 index 00000000000..249d6b0be8d --- /dev/null +++ b/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Input renders correctly 1`] = ` +
+ +
+`; diff --git a/public/app/core/components/Form/index.ts b/public/app/core/components/Form/index.ts new file mode 100644 index 00000000000..e4c8197aaa9 --- /dev/null +++ b/public/app/core/components/Form/index.ts @@ -0,0 +1,3 @@ +export { Element } from './Element'; +export { Input } from './Input'; +export { Label } from './Label'; diff --git a/public/app/core/components/JSONFormatter/JSONFormatter.tsx b/public/app/core/components/JSONFormatter/JSONFormatter.tsx new file mode 100644 index 00000000000..73c055de94b --- /dev/null +++ b/public/app/core/components/JSONFormatter/JSONFormatter.tsx @@ -0,0 +1,51 @@ +import React, { PureComponent, createRef } from 'react'; +// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js'; +import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now + +interface Props { + className?: string; + json: {}; + config?: any; + open?: number; + onDidRender?: (formattedJson: any) => void; +} + +export class JSONFormatter extends PureComponent { + private wrapperRef = createRef(); + + static defaultProps = { + open: 3, + config: { + animateOpen: true, + }, + }; + + componentDidMount() { + this.renderJson(); + } + + componentDidUpdate() { + this.renderJson(); + } + + renderJson = () => { + const { json, config, open, onDidRender } = this.props; + const wrapperEl = this.wrapperRef.current; + const formatter = new JsonExplorer(json, open, config); + const hasChildren: boolean = wrapperEl.hasChildNodes(); + if (hasChildren) { + wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); + } else { + wrapperEl.appendChild(formatter.render()); + } + + if (onDidRender) { + onDidRender(formatter.json); + } + }; + + render() { + const { className } = this.props; + return
; + } +} diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.html b/public/app/core/components/manage_dashboards/manage_dashboards.html index 941c2dfd6a4..6fbd65afaf5 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.html +++ b/public/app/core/components/manage_dashboards/manage_dashboards.html @@ -67,7 +67,7 @@
diff --git a/public/app/core/components/search/search_results.html b/public/app/core/components/search/search_results.html index dc5e5b580bb..92850e0ea5d 100644 --- a/public/app/core/components/search/search_results.html +++ b/public/app/core/components/search/search_results.html @@ -1,11 +1,11 @@
-
+
+ switch-class="gf-form-checkbox--transparent">
@@ -21,12 +21,12 @@
-
+
+ switch-class="gf-form-checkbox--transparent">
diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index d51c4cf83d6..7d295b27726 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -14,3 +14,4 @@ export const DASHBOARD_TOP_PADDING = 20; export const PANEL_HEADER_HEIGHT = 27; export const PANEL_BORDER = 2; +export const PANEL_OPTIONS_KEY_PREFIX = 'options-'; diff --git a/public/app/core/services/AngularLoader.ts b/public/app/core/services/AngularLoader.ts index 3f5c535297f..54dd9a35767 100644 --- a/public/app/core/services/AngularLoader.ts +++ b/public/app/core/services/AngularLoader.ts @@ -5,6 +5,7 @@ import _ from 'lodash'; export interface AngularComponent { destroy(); digest(); + getScope(); } export class AngularLoader { @@ -28,6 +29,9 @@ export class AngularLoader { digest: () => { scope.$digest(); }, + getScope: () => { + return scope; + }, }; } } diff --git a/public/app/core/table_model.ts b/public/app/core/table_model.ts index 91a7cd0c1fb..fd99db69249 100644 --- a/public/app/core/table_model.ts +++ b/public/app/core/table_model.ts @@ -86,11 +86,10 @@ export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): if (arguments.length === 1) { return model; } - // Single query returns data columns and rows as is if (arguments.length === 2) { - model.columns = [...tables[0].columns]; - model.rows = [...tables[0].rows]; + model.columns = tables[0].hasOwnProperty('columns') ? [...tables[0].columns] : []; + model.rows = tables[0].hasOwnProperty('rows') ? [...tables[0].rows] : []; return model; } diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts index 2079aa39006..0150e80f1ed 100644 --- a/public/app/core/utils/rangeutil.ts +++ b/public/app/core/utils/rangeutil.ts @@ -159,3 +159,12 @@ export function describeTimeRange(range: RawTimeRange): string { return range.from.toString() + ' to ' + range.to.toString(); } + +export const isValidTimeSpan = (value: string) => { + if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) { + return true; + } + + const info = describeTextRange(value); + return info.invalid !== true; +}; diff --git a/public/app/core/utils/validate.ts b/public/app/core/utils/validate.ts new file mode 100644 index 00000000000..c6663882808 --- /dev/null +++ b/public/app/core/utils/validate.ts @@ -0,0 +1,16 @@ +import { ValidationRule, ValidationEvents } from 'app/types'; +import { EventsWithValidation } from 'app/core/components/Form/Input'; + +export const validate = (value: string, validationRules: ValidationRule[]) => { + const errors = validationRules.reduce((acc, currRule) => { + if (!currRule.rule(value)) { + return acc.concat(currRule.errorMessage); + } + return acc; + }, []); + return errors.length > 0 ? errors : null; +}; + +export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => { + return validationEvents && validationEvents[event]; +}; diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts index ef68ddcf4a5..5a8abee00d7 100644 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import coreModule from 'app/core/core_module'; import { ThresholdMapper } from './state/ThresholdMapper'; import { QueryPart } from 'app/core/components/query_part/query_part'; import alertDef from './state/alertDef'; @@ -430,3 +431,5 @@ export function alertTab() { controller: AlertTabCtrl, }; } + +coreModule.directive('alertTab', alertTab); diff --git a/public/app/features/dashboard/dashgrid/AlertTab.tsx b/public/app/features/dashboard/dashgrid/AlertTab.tsx new file mode 100644 index 00000000000..7df7864c758 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/AlertTab.tsx @@ -0,0 +1,72 @@ +import React, { PureComponent } from 'react'; + +import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; +import { EditorTabBody } from './EditorTabBody'; +import 'app/features/alerting/AlertTabCtrl'; + +interface Props { + angularPanel?: AngularComponent; +} + +export class AlertTab extends PureComponent { + element: any; + component: AngularComponent; + + constructor(props) { + super(props); + } + + componentDidMount() { + if (this.shouldLoadAlertTab()) { + this.loadAlertTab(); + } + } + + componentDidUpdate(prevProps: Props) { + if (this.shouldLoadAlertTab()) { + this.loadAlertTab(); + } + } + + shouldLoadAlertTab() { + return this.props.angularPanel && this.element; + } + + componentWillUnmount() { + if (this.component) { + this.component.destroy(); + } + } + + loadAlertTab() { + 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 = scope.$$childHead.ctrl; + const loader = getAngularLoader(); + const template = ''; + + const scopeProps = { + ctrl: panelCtrl, + }; + + this.component = loader.load(this.element, scopeProps, template); + } + + render() { + return ( + +
(this.element = element)} /> + + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 84db3a2cf99..6df8a8bc0aa 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -176,10 +176,9 @@ export class DashboardGrid extends React.Component { renderPanels() { const panelElements = []; - console.log('render panels'); for (const panel of this.props.dashboard.panels) { - const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen }); + const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.fullscreen }); panelElements.push(
{ - element: any; - angularPanel: AngularComponent; + element: HTMLElement; specialPanels = {}; constructor(props) { @@ -35,6 +36,7 @@ export class DashboardPanel extends PureComponent { this.state = { plugin: null, + angularPanel: null, }; this.specialPanels['row'] = this.renderRow.bind(this); @@ -54,11 +56,7 @@ export class DashboardPanel extends PureComponent { } onPluginTypeChanged = (plugin: PanelPlugin) => { - this.props.panel.changeType(plugin.id); - this.loadPlugin(); - }; - - onAngularPluginTypeChanged = () => { + this.props.panel.changeType(plugin.id, this.state.angularPanel !== null); this.loadPlugin(); }; @@ -93,28 +91,30 @@ export class DashboardPanel extends PureComponent { } componentDidUpdate() { - this.loadPlugin(); - - // handle angular plugin loading - if (!this.element || this.angularPanel) { + if (!this.element || this.state.angularPanel) { return; } const loader = getAngularLoader(); const template = ''; const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard }; - this.angularPanel = loader.load(this.element, scopeProps, template); + const angularPanel = loader.load(this.element, scopeProps, template); + + this.setState({ angularPanel }); } - cleanUpAngularPanel() { - if (this.angularPanel) { - this.angularPanel.destroy(); - this.angularPanel = null; + cleanUpAngularPanel(unmounted?: boolean) { + if (this.state.angularPanel) { + this.state.angularPanel.destroy(); + + if (!unmounted) { + this.setState({ angularPanel: null }); + } } } componentWillUnmount() { - this.cleanUpAngularPanel(); + this.cleanUpAngularPanel(true); } onMouseEnter = () => { @@ -129,25 +129,16 @@ export class DashboardPanel extends PureComponent { const { dashboard, panel } = this.props; const { plugin } = this.state; - const containerClass = this.props.isEditing ? 'panel-editor-container' : 'panel-height-helper'; - const panelWrapperClass = this.props.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper'; - // this might look strange with these classes that change when edit, but - // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel - return ( -
-
- -
- {panel.isEditing && ( - - )} -
- ); + return ; + } + + renderAngularPanel() { + return
(this.element = element)} className="panel-height-helper" />; } render() { - const { panel } = this.props; - const { plugin } = this.state; + const { panel, dashboard, isFullscreen, isEditing } = this.props; + const { plugin, angularPanel } = this.state; if (this.isSpecial()) { return this.specialPanels[panel.type](); @@ -158,19 +149,29 @@ export class DashboardPanel extends PureComponent { return null; } - // if exporting PanelComponent it must be a react panel - if (plugin.exports.Panel) { - return this.renderReactPanel(); - } + const containerClass = classNames({ 'panel-editor-container': isEditing, 'panel-height-helper': !isEditing }); + const panelWrapperClass = classNames({ + 'panel-wrapper': true, + 'panel-wrapper--edit': isEditing, + 'panel-wrapper--view': isFullscreen && !isEditing, + }); - // legacy angular rendering return ( -
(this.element = element)} - className="panel-height-helper" - onMouseEnter={this.onMouseEnter} - onMouseLeave={this.onMouseLeave} - /> +
+
+ {plugin.exports.Panel && this.renderReactPanel()} + {plugin.exports.PanelCtrl && this.renderAngularPanel()} +
+ {panel.isEditing && ( + + )} +
); } } diff --git a/public/app/features/dashboard/dashgrid/DataSourceOption.tsx b/public/app/features/dashboard/dashgrid/DataSourceOption.tsx new file mode 100644 index 00000000000..0adfc4abe16 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DataSourceOption.tsx @@ -0,0 +1,31 @@ +import React, { SFC } from 'react'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; + +interface Props { + label: string; + placeholder?: string; + name?: string; + value?: string; + onChange?: (evt: any) => void; + tooltipInfo?: any; +} + +export const DataSourceOptions: SFC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => { + const dsOption = ( +
+ + onChange(evt.target.value)} + /> +
+ ); + + return tooltipInfo ? {dsOption} : dsOption; +}; + +export default DataSourceOptions; diff --git a/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx b/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx index c40ca753a8e..9a3923a09f2 100644 --- a/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx +++ b/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx @@ -2,13 +2,14 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import _ from 'lodash'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DataSourceSelectItem } from 'app/types'; -interface Props {} +interface Props { + onChangeDataSource: (ds: any) => void; + datasources: DataSourceSelectItem[]; +} interface State { - datasources: DataSourceSelectItem[]; searchQuery: string; } @@ -17,31 +18,32 @@ export class DataSourcePicker extends PureComponent { constructor(props) { super(props); - this.state = { - datasources: getDatasourceSrv().getMetricSources(), searchQuery: '', }; } getDataSources() { - const { datasources, searchQuery } = this.state; + const { searchQuery } = this.state; const regex = new RegExp(searchQuery, 'i'); + const { datasources } = this.props; const filtered = datasources.filter(item => { return regex.test(item.name) || regex.test(item.meta.name); }); - return _.sortBy(filtered, 'sort'); + return filtered; } - renderDataSource = (ds: DataSourceSelectItem, index) => { + renderDataSource = (ds: DataSourceSelectItem, index: number) => { + const { onChangeDataSource } = this.props; + const onClick = () => onChangeDataSource(ds); const cssClass = classNames({ 'ds-picker-list__item': true, }); return ( -
+
{ds.name}
@@ -54,7 +56,16 @@ export class DataSourcePicker extends PureComponent { }, 300); } + onSearchQueryChange = evt => { + const value = evt.target.value; + this.setState(prevState => ({ + ...prevState, + searchQuery: value, + })); + }; + renderFilters() { + const { searchQuery } = this.state; return ( <> -
- - -
); } diff --git a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx b/public/app/features/dashboard/dashgrid/EditorTabBody.tsx index 86ebdf86b67..8f213533a1e 100644 --- a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx +++ b/public/app/features/dashboard/dashgrid/EditorTabBody.tsx @@ -4,7 +4,8 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn'; interface Props { children: JSX.Element; - main: EditorToolBarView; + heading: string; + main?: EditorToolBarView; toolbarItems: EditorToolBarView[]; } @@ -12,11 +13,14 @@ export interface EditorToolBarView { title: string; imgSrc?: string; icon?: string; - render: () => JSX.Element; + disabled?: boolean; + onClick?: () => void; + render: (closeFunction: any) => JSX.Element | JSX.Element[]; } interface State { openView?: EditorToolBarView; + fadeIn: boolean; } export class EditorTabBody extends PureComponent { @@ -25,9 +29,14 @@ export class EditorTabBody extends PureComponent { this.state = { openView: null, + fadeIn: false, }; } + componentDidMount() { + this.setState({ fadeIn: true }); + } + onToggleToolBarView = (item: EditorToolBarView) => { this.setState({ openView: item === this.state.openView ? null : item, @@ -38,9 +47,24 @@ export class EditorTabBody extends PureComponent { this.setState({ openView: null }); }; + static getDerivedStateFromProps(props, state) { + if (state.openView) { + const activeToolbarItem = props.toolbarItems.find( + item => item.title === state.openView.title && item.icon === state.openView.icon + ); + if (activeToolbarItem) { + return { + ...state, + openView: activeToolbarItem, + }; + } + } + return state; + } + renderMainSelection(view: EditorToolBarView) { return ( -
this.onToggleToolBarView(view)} key={view.title}> +
this.onToggleToolBarView(view)} key={view.title + view.icon}>
{view.title}
@@ -49,9 +73,16 @@ export class EditorTabBody extends PureComponent { } renderButton(view: EditorToolBarView) { + const onClick = () => { + if (view.onClick) { + view.onClick(); + } + this.onToggleToolBarView(view); + }; + return ( -
-
@@ -64,29 +95,32 @@ export class EditorTabBody extends PureComponent { - {view.render()} + {view.render(this.onCloseOpenView)}
); } render() { - const { children, toolbarItems, main } = this.props; - const { openView } = this.state; + const { children, toolbarItems, main, heading } = this.props; + const { openView, fadeIn } = this.state; return ( <>
- {this.renderMainSelection(main)} +
{heading}
+ {main && this.renderMainSelection(main)}
{toolbarItems.map(item => this.renderButton(item))}
+ +
{openView && this.renderOpenView(openView)}
+
- - {openView && this.renderOpenView(openView)} + + {children} - {children}
diff --git a/public/app/features/dashboard/dashgrid/GeneralTab.tsx b/public/app/features/dashboard/dashgrid/GeneralTab.tsx new file mode 100644 index 00000000000..96694e346df --- /dev/null +++ b/public/app/features/dashboard/dashgrid/GeneralTab.tsx @@ -0,0 +1,52 @@ +import React, { PureComponent } from 'react'; + +import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; +import { EditorTabBody } from './EditorTabBody'; + +import { PanelModel } from '../panel_model'; +import './../../panel/GeneralTabCtrl'; + +interface Props { + panel: PanelModel; +} + +export class GeneralTab extends PureComponent { + element: any; + component: AngularComponent; + + constructor(props) { + super(props); + } + + componentDidMount() { + if (!this.element) { + return; + } + + const { panel } = this.props; + + const loader = getAngularLoader(); + const template = ''; + const scopeProps = { + ctrl: { + panel: panel, + }, + }; + + this.component = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.component) { + this.component.destroy(); + } + } + + render() { + return ( + +
(this.element = element)} /> + + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx index 09a8c7052e3..fbf51e1e86c 100644 --- a/public/app/features/dashboard/dashgrid/PanelEditor.tsx +++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx @@ -3,38 +3,36 @@ import classNames from 'classnames'; import { QueriesTab } from './QueriesTab'; import { VisualizationTab } from './VisualizationTab'; +import { GeneralTab } from './GeneralTab'; +import { AlertTab } from './AlertTab'; +import config from 'app/core/config'; import { store } from 'app/store/store'; import { updateLocation } from 'app/core/actions'; +import { AngularComponent } from 'app/core/services/AngularLoader'; import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; import { PanelPlugin } from 'app/types/plugins'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; + interface PanelEditorProps { panel: PanelModel; dashboard: DashboardModel; plugin: PanelPlugin; + angularPanel?: AngularComponent; onTypeChanged: (newType: PanelPlugin) => void; } interface PanelEditorTab { id: string; text: string; - icon: string; } export class PanelEditor extends PureComponent { - tabs: PanelEditorTab[]; - constructor(props) { super(props); - - this.tabs = [ - { id: 'queries', text: 'Queries', icon: 'fa fa-database' }, - { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' }, - { id: 'alert', text: 'Alert', icon: 'gicon gicon-alert' }, - ]; } onChangeTab = (tab: PanelEditorTab) => { @@ -47,44 +45,64 @@ export class PanelEditor extends PureComponent { this.forceUpdate(); }; - onClose = () => { - store.dispatch( - updateLocation({ - query: { tab: null, fullscreen: null, edit: null }, - partial: true, - }) - ); - }; + renderCurrentTab(activeTab: string) { + const { panel, dashboard, onTypeChanged, plugin, angularPanel } = this.props; + + switch (activeTab) { + case 'advanced': + return ; + case 'queries': + return ; + case 'alerts': + return ; + case 'visualization': + return ( + + ); + default: + return null; + } + } render() { - const { panel, dashboard, onTypeChanged, plugin } = this.props; - const { location } = store.getState(); - const activeTab = location.query.tab || 'queries'; + const { plugin } = this.props; + const activeTab = store.getState().location.query.tab || 'queries'; + + const tabs = [ + { id: 'queries', text: 'Queries' }, + { id: 'visualization', text: 'Visualization' }, + { id: 'advanced', text: 'Advanced' }, + ]; + + if (config.alertingEnabled && plugin.id === 'graph') { + tabs.push({ + id: 'alerts', + text: 'Alerts', + }); + } return (
-
-
-
-
-
+ { + //
+ //
+ //
+ //
+ //
+ }
-
    - {this.tabs.map(tab => { - return ; - })} -
- - + {tabs.map(tab => { + return ; + })}
- - {activeTab === 'queries' && } - {activeTab === 'visualization' && ( - - )} +
{this.renderCurrentTab(activeTab)}
); } @@ -98,15 +116,17 @@ interface TabItemParams { function TabItem({ tab, activeTab, onClick }: TabItemParams) { const tabClasses = classNames({ - 'gf-tabs-link': true, + 'panel-editor-tabs__link': true, active: activeTab === tab.id, }); return ( -
  • onClick(tab)}> +
    onClick(tab)}> - {tab.text} + + + -
  • +
    ); } diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx index be8ffe30666..f202aca4277 100644 --- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -1,23 +1,62 @@ -import React, { PureComponent } from 'react'; - +import React, { SFC, PureComponent } from 'react'; +import DataSourceOption from './DataSourceOption'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; import { EditorTabBody } from './EditorTabBody'; import { DataSourcePicker } from './DataSourcePicker'; - import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; +import './../../panel/metrics_tab'; +import config from 'app/core/config'; +import { QueryInspector } from './QueryInspector'; +import { TimeRangeOptions } from './TimeRangeOptions'; + +// Services +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv'; +import { DataSourceSelectItem } from 'app/types'; + +import Remarkable from 'remarkable'; interface Props { panel: PanelModel; dashboard: DashboardModel; } -export class QueriesTab extends PureComponent { +interface Help { + isLoading: boolean; + helpHtml: any; +} + +interface State { + currentDatasource: DataSourceSelectItem; + help: Help; + hideTimeOverride: boolean; +} + +interface LoadingPlaceholderProps { + text: string; +} + +const LoadingPlaceholder: SFC = ({ text }) =>

    {text}

    ; + +export class QueriesTab extends PureComponent { element: any; component: AngularComponent; + datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources(); + backendSrv: BackendSrv = getBackendSrv(); constructor(props) { super(props); + const { panel } = props; + + this.state = { + currentDatasource: this.datasources.find(datasource => datasource.value === panel.datasource), + help: { + isLoading: false, + helpHtml: null, + }, + hideTimeOverride: false, + }; } componentDidMount() { @@ -26,7 +65,6 @@ export class QueriesTab extends PureComponent { } const { panel, dashboard } = this.props; - const loader = getAngularLoader(); const template = ''; const scopeProps = { @@ -46,27 +84,191 @@ export class QueriesTab extends PureComponent { } } + onChangeDataSource = datasource => { + const { panel } = this.props; + const { currentDatasource } = this.state; + // switching to mixed + if (datasource.meta.mixed) { + panel.targets.forEach(target => { + target.datasource = panel.datasource; + if (!target.datasource) { + target.datasource = config.defaultDatasource; + } + }); + } else if (currentDatasource && currentDatasource.meta.mixed) { + panel.targets.forEach(target => { + delete target.datasource; + }); + } + + panel.datasource = datasource.value; + panel.refresh(); + + this.setState(prevState => ({ + ...prevState, + currentDatasource: datasource, + })); + }; + + loadHelp = () => { + const { currentDatasource } = this.state; + const hasHelp = currentDatasource.meta.hasQueryHelp; + + if (hasHelp) { + this.setState(prevState => ({ + ...prevState, + help: { + helpHtml:

    Loading help...

    , + isLoading: true, + }, + })); + + this.backendSrv + .get(`/api/plugins/${currentDatasource.meta.id}/markdown/query_help`) + .then(res => { + const md = new Remarkable(); + const helpHtml = md.render(res); // TODO: Clean out dangerous code? Previous: this.helpHtml = this.$sce.trustAsHtml(md.render(res)); + this.setState(prevState => ({ + ...prevState, + help: { + helpHtml:
    , + isLoading: false, + }, + })); + }) + .catch(() => { + this.setState(prevState => ({ + ...prevState, + help: { + helpHtml: 'Error occured when loading help', + isLoading: false, + }, + })); + }); + } + }; + + renderOptions = close => { + const { currentDatasource } = this.state; + const { queryOptions } = currentDatasource.meta; + const { panel } = this.props; + + const onChangeFn = (panelKey: string) => { + return (value: string | number) => { + panel[panelKey] = value; + panel.refresh(); + }; + }; + + const allOptions = { + cacheTimeout: { + label: 'Cache timeout', + placeholder: '60', + name: 'cacheTimeout', + value: panel.cacheTimeout, + tooltipInfo: ( + <> + If your time series store has a query cache this option can override the default cache timeout. Specify a + numeric value in seconds. + + ), + }, + maxDataPoints: { + label: 'Max data points', + placeholder: 'auto', + name: 'maxDataPoints', + value: panel.maxDataPoints, + tooltipInfo: ( + <> + The maximum data points the query should return. For graphs this is automatically set to one data point per + pixel. + + ), + }, + minInterval: { + label: 'Min time interval', + placeholder: '0', + name: 'minInterval', + value: panel.interval, + panelKey: 'interval', + tooltipInfo: ( + <> + A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '} + 1m if your data is written every minute. Access auto interval via variable{' '} + $__interval for time range string and $__interval_ms for numeric variable that can + be used in math expressions. + + ), + }, + }; + + const dsOptions = queryOptions + ? Object.keys(queryOptions).map(key => { + const options = allOptions[key]; + return ; + }) + : null; + + return ( + <> + + {dsOptions} + + ); + }; + + renderQueryInspector = () => { + const { panel } = this.props; + return ; + }; + + renderHelp = () => { + const { helpHtml, isLoading } = this.state.help; + return isLoading ? : helpHtml; + }; + render() { - const currentDataSource = { - title: 'ProductionDB', - imgSrc: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg', - render: () => , + const { currentDatasource } = this.state; + const { hasQueryHelp } = currentDatasource.meta; + const dsInformation = { + title: currentDatasource.name, + imgSrc: currentDatasource.meta.info.logos.small, + render: closeOpenView => ( + { + closeOpenView(); + this.onChangeDataSource(ds); + }} + /> + ), }; const queryInspector = { title: 'Query Inspector', - render: () =>

    hello

    , + render: this.renderQueryInspector, }; const dsHelp = { title: '', icon: 'fa fa-question', - render: () =>

    hello

    , + disabled: !hasQueryHelp, + onClick: this.loadHelp, + render: this.renderHelp, + }; + + const options = { + title: '', + icon: 'fa fa-cog', + disabled: false, + render: this.renderOptions, }; return ( - -
    (this.element = element)} style={{ width: '100%' }} /> + + <> +
    (this.element = element)} style={{ width: '100%' }} /> + ); } diff --git a/public/app/features/dashboard/dashgrid/QueryInspector.tsx b/public/app/features/dashboard/dashgrid/QueryInspector.tsx new file mode 100644 index 00000000000..ec618404651 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/QueryInspector.tsx @@ -0,0 +1,226 @@ +import React, { PureComponent } from 'react'; +import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter'; +import appEvents from 'app/core/app_events'; +import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; + +interface DsQuery { + isLoading: boolean; + response: {}; +} + +interface Props { + panel: any; + LoadingPlaceholder: any; +} + +interface State { + allNodesExpanded: boolean; + isMocking: boolean; + mockedResponse: string; + dsQuery: DsQuery; +} + +export class QueryInspector extends PureComponent { + formattedJson: any; + clipboard: any; + + constructor(props) { + super(props); + this.state = { + allNodesExpanded: null, + isMocking: false, + mockedResponse: '', + dsQuery: { + isLoading: false, + response: {}, + }, + }; + } + + componentDidMount() { + const { panel } = this.props; + panel.events.on('refresh', this.onPanelRefresh); + appEvents.on('ds-request-response', this.onDataSourceResponse); + panel.refresh(); + } + + componentWillUnmount() { + const { panel } = this.props; + appEvents.off('ds-request-response', this.onDataSourceResponse); + panel.events.off('refresh', this.onPanelRefresh); + } + + handleMocking(response) { + const { mockedResponse } = this.state; + let mockedData; + try { + mockedData = JSON.parse(mockedResponse); + } catch (err) { + appEvents.emit('alert-error', ['R: Failed to parse mocked response']); + return; + } + + response.data = mockedData; + } + + onPanelRefresh = () => { + this.setState(prevState => ({ + ...prevState, + dsQuery: { + isLoading: true, + response: {}, + }, + })); + }; + + onDataSourceResponse = (response: any = {}) => { + if (this.state.isMocking) { + this.handleMocking(response); + return; + } + + response = { ...response }; // clone - dont modify the response + + if (response.headers) { + delete response.headers; + } + + if (response.config) { + response.request = response.config; + delete response.config; + delete response.request.transformRequest; + delete response.request.transformResponse; + delete response.request.paramSerializer; + delete response.request.jsonpCallbackParam; + delete response.request.headers; + delete response.request.requestId; + delete response.request.inspect; + delete response.request.retry; + delete response.request.timeout; + } + + if (response.data) { + response.response = response.data; + + delete response.data; + delete response.status; + delete response.statusText; + delete response.$$config; + } + this.setState(prevState => ({ + ...prevState, + dsQuery: { + isLoading: false, + response: response, + }, + })); + }; + + setFormattedJson = formattedJson => { + this.formattedJson = formattedJson; + }; + + getTextForClipboard = () => { + return JSON.stringify(this.formattedJson, null, 2); + }; + + onClipboardSuccess = () => { + appEvents.emit('alert-success', ['Content copied to clipboard']); + }; + + onToggleExpand = () => { + this.setState(prevState => ({ + ...prevState, + allNodesExpanded: !this.state.allNodesExpanded, + })); + }; + + onToggleMocking = () => { + this.setState(prevState => ({ + ...prevState, + isMocking: !this.state.isMocking, + })); + }; + + getNrOfOpenNodes = () => { + if (this.state.allNodesExpanded === null) { + return 3; // 3 is default, ie when state is null + } else if (this.state.allNodesExpanded) { + return 20; + } + return 1; + }; + + setMockedResponse = evt => { + const mockedResponse = evt.target.value; + this.setState(prevState => ({ + ...prevState, + mockedResponse, + })); + }; + + renderExpandCollapse = () => { + const { allNodesExpanded } = this.state; + + const collapse = ( + <> + Collapse All + + ); + const expand = ( + <> + Expand All + + ); + return allNodesExpanded ? collapse : expand; + }; + + render() { + const { response, isLoading } = this.state.dsQuery; + const { LoadingPlaceholder } = this.props; + const { isMocking } = this.state; + const openNodes = this.getNrOfOpenNodes(); + + if (isLoading) { + return ; + } + + return ( + <> +
    + {/* + + */} + + + + Copy to Clipboard + +
    + + {!isMocking && } + {isMocking && ( +
    +
    +