Merge pull request #14274 from grafana/develop

Develop (New Panel Edit UX & Explore All Datasources suppport) -> Master
This commit is contained in:
Torkel Ödegaard 2018-12-17 13:35:59 +01:00 committed by GitHub
commit 3efaf52049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
280 changed files with 8916 additions and 3495 deletions

1
.gitignore vendored
View File

@ -76,3 +76,4 @@ debug.test
/devenv/bulk_alerting_dashboards/*.json
/scripts/build/release_publisher/release_publisher
*.patch

View File

@ -25,7 +25,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies
- Go (Latest Stable)
- NodeJS LTS
- Node.js LTS
### Building the backend
```bash
@ -37,7 +37,7 @@ go run build.go build
### Building frontend assets
For this you need nodejs (v.6+).
For this you need Node.js (LTS version).
To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
```bash

View File

@ -20,9 +20,9 @@
"@types/enzyme": "^3.1.13",
"@types/jest": "^23.3.2",
"@types/node": "^8.0.31",
"@types/react": "^16.4.14",
"@types/react": "^16.7.6",
"@types/react-custom-scrollbars": "^4.0.5",
"@types/react-dom": "^16.0.7",
"@types/react-dom": "^16.0.9",
"@types/react-select": "^2.0.4",
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
@ -148,17 +148,18 @@
"prismjs": "^1.6.0",
"prop-types": "^15.6.2",
"rc-cascader": "^0.14.0",
"react": "^16.5.0",
"react": "^16.6.3",
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.5.0",
"react-dom": "^16.6.3",
"react-grid-layout": "0.16.6",
"react-popper": "^1.3.0",
"react-highlight-words": "0.11.0",
"react-popper": "^0.7.5",
"react-redux": "^5.0.7",
"react-select": "2.1.0",
"@torkelo/react-select": "2.1.1",
"react-sizeme": "^2.3.6",
"react-table": "^6.8.6",
"react-transition-group": "^2.2.1",
"react-virtualized": "^9.21.0",
"redux": "^4.0.0",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
@ -175,6 +176,7 @@
"tslint-react": "^3.6.0"
},
"resolutions": {
"caniuse-db": "1.0.30000772"
"caniuse-db": "1.0.30000772",
"**/@types/react": "16.7.6"
}
}

View File

@ -1,17 +1,17 @@
#! /usr/bin/env bash
version=5.4.1
version=5.4.2
wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
# wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
#
# package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
# package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
# package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
#
# package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
# package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
# package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm
wget https://dl.grafana.com/oss/release/grafana-${version}-1.x86_64.rpm
package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose

View File

@ -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"`

View File

@ -0,0 +1,38 @@
import React, { SFC } from 'react';
import Transition from 'react-transition-group/Transition';
interface Props {
duration: number;
children: JSX.Element;
in: boolean;
unmountOnExit?: boolean;
}
export const FadeIn: SFC<Props> = props => {
const defaultStyle = {
transition: `opacity ${props.duration}ms linear`,
opacity: 0,
};
const transitionStyles = {
exited: { opacity: 0, display: 'none' },
entering: { opacity: 0 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
};
return (
<Transition in={props.in} timeout={props.duration} unmountOnExit={props.unmountOnExit || false}>
{state => (
<div
style={{
...defaultStyle,
...transitionStyles[state],
}}
>
{props.children}
</div>
)}
</Transition>
);
};

View File

@ -23,7 +23,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = de
const transitionStyles = {
exited: { maxHeight: 0 },
entering: { maxHeight: maxHeight },
entered: { maxHeight: maxHeight, overflow: 'visible' },
entered: { maxHeight: 'unset', overflow: 'visible' },
exiting: { maxHeight: 0 },
};

View File

@ -20,6 +20,7 @@ export default class AppNotificationItem extends Component<Props> {
render() {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">

View File

@ -0,0 +1,36 @@
import { PureComponent } from 'react';
import ReactDOM from 'react-dom';
export interface Props {
onClick: () => void;
}
interface State {
hasEventListener: boolean;
}
export class ClickOutsideWrapper extends PureComponent<Props, State> {
state = {
hasEventListener: false,
};
componentDidMount() {
window.addEventListener('click', this.onOutsideClick, false);
}
componentWillUnmount() {
window.removeEventListener('click', this.onOutsideClick, false);
}
onOutsideClick = event => {
const domNode = ReactDOM.findDOMNode(this) as Element;
if (!domNode || !domNode.contains(event.target)) {
this.props.onClick();
}
};
render() {
return this.props.children;
}
}

View File

@ -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<Props> {
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
);
}
}

View File

@ -28,8 +28,8 @@ class CustomScrollbar extends PureComponent<Props> {
<Scrollbars
className={customClassName}
autoHeight={true}
autoHeightMin={'100%'}
autoHeightMax={'100%'}
autoHeightMin={'inherit'}
autoHeightMax={'inherit'}
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
renderTrackVertical={props => <div {...props} className="track-vertical" />}
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}

View File

@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
style={
Object {
"height": "auto",
"maxHeight": "100%",
"minHeight": "100%",
"maxHeight": "inherit",
"minHeight": "inherit",
"overflow": "hidden",
"position": "relative",
"width": "100%",
@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
"left": undefined,
"marginBottom": 0,
"marginRight": 0,
"maxHeight": "calc(100% + 0px)",
"minHeight": "calc(100% + 0px)",
"maxHeight": "calc(inherit + 0px)",
"minHeight": "calc(inherit + 0px)",
"overflow": "scroll",
"position": "relative",
"right": undefined,

View File

@ -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<any>;
}
export class Element extends PureComponent<Props> {
elementId: string = this.props.id || uniqueId('form-element-');
get elementLabel() {
const { label, labelClassName } = this.props;
if (label) {
return (
<Label htmlFor={this.elementId} className={labelClassName}>
{label}
</Label>
);
}
return null;
}
get children() {
const { children } = this.props;
return React.cloneElement(children, { id: this.elementId });
}
render() {
return (
<div className="our-custom-wrapper-class">
{this.elementLabel}
{this.children}
</div>
);
}
}

View File

@ -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(<Input />).toJSON();
expect(tree).toMatchSnapshot();
});
it('should validate with error onBlur', () => {
const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
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(<Input validationEvents={testBlurValidation} />);
const evt = {
persist: jest.fn,
target: {
value: 'Hi',
},
};
wrapper.find('input').simulate('blur', evt);
expect(wrapper.state('error')).toBe(null);
});
});

View File

@ -0,0 +1,94 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
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<HTMLInputElement> {
validationEvents?: ValidationEvents;
hideErrorMessage?: boolean;
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
}
export class Input extends PureComponent<Props> {
static defaultProps = {
className: '',
};
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 = classNames('gf-form-input', { invalid: this.isInvalid }, className);
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
return (
<div className="our-custom-wrapper-class">
<input {...inputElementProps} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>}
</div>
);
}
}

View File

@ -0,0 +1,19 @@
import React, { PureComponent, ReactNode } from 'react';
interface Props {
children: ReactNode;
htmlFor?: string;
className?: string;
}
export class Label extends PureComponent<Props> {
render() {
const { children, htmlFor, className } = this.props;
return (
<label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
{children}
</label>
);
}
}

View File

@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Input renders correctly 1`] = `
<div
className="our-custom-wrapper-class"
>
<input
className="gf-form-input"
/>
</div>
`;

View File

@ -0,0 +1,3 @@
export { Element } from './Element';
export { Input } from './Input';
export { Label } from './Label';

View File

@ -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<Props> {
private wrapperRef = createRef<HTMLDivElement>();
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 <div className={className} ref={this.wrapperRef} />;
}
}

View File

@ -6,6 +6,7 @@ interface Props {
for?: string;
children: ReactNode;
width?: number;
className?: string;
}
export const Label: SFC<Props> = props => {

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { UserPicker } from 'app/core/components/Picker/UserPicker';
import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
import { UserPicker } from 'app/core/components/Select/UserPicker';
import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker';
import { Select, SelectOptionItem } from 'app/core/components/Select/Select';
import { User } from 'app/types';
import {
dashboardPermissionLevels,
@ -61,7 +61,7 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
};
onPermissionChanged = (permission: OptionWithDescription) => {
onPermissionChanged = (permission: SelectOptionItem) => {
this.setState({ permission: permission.value });
};
@ -121,11 +121,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
) : null}
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={dashboardPermissionLevels}
onSelected={this.onPermissionChanged}
disabled={false}
className={'gf-form-select-box__control--menu-right'}
<Select
isSearchable={false}
options={dashboardPermissionLevels}
onChange={this.onPermissionChanged}
className="gf-form-select-box__control--menu-right"
/>
</div>

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import Select from 'app/core/components/Select/Select';
import { dashboardPermissionLevels } from 'app/types/acl';
export interface Props {
@ -9,6 +9,7 @@ export interface Props {
export default class DisabledPermissionListItem extends Component<Props, any> {
render() {
const { item } = this.props;
const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
return (
<tr className="gf-form-disabled">
@ -23,12 +24,12 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={dashboardPermissionLevels}
onSelected={() => {}}
disabled={true}
className={'gf-form-select-box__control--menu-right'}
value={item.permission}
<Select
options={dashboardPermissionLevels}
onChange={() => {}}
isDisabled={true}
className="gf-form-select-box__control--menu-right"
value={currentPermissionLevel}
/>
</div>
</td>

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { Select } from 'app/core/components/Select/Select';
import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
import { FolderInfo } from 'app/types';
@ -50,6 +50,7 @@ export default class PermissionsListItem extends PureComponent<Props> {
render() {
const { item, folderInfo } = this.props;
const inheritedFromRoot = item.dashboardId === -1 && !item.inherited;
const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
return (
<tr className={setClassNameHelper(item.inherited)}>
@ -74,12 +75,13 @@ export default class PermissionsListItem extends PureComponent<Props> {
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={dashboardPermissionLevels}
onSelected={this.onPermissionChanged}
disabled={item.inherited}
className={'gf-form-select-box__control--menu-right'}
value={item.permission}
<Select
isSearchable={false}
options={dashboardPermissionLevels}
onChange={this.onPermissionChanged}
isDisabled={item.inherited}
className="gf-form-select-box__control--menu-right"
value={currentPermissionLevel}
/>
</div>
</td>

View File

@ -1,25 +0,0 @@
import React from 'react';
import { components } from 'react-select';
import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> {
data: any;
}
export const Option = (props: ExtendedOptionProps) => {
const { children, isSelected, data, className } = props;
return (
<components.Option {...props}>
<div className={`description-picker-option__button btn btn-link ${className}`}>
{isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
<div className="gf-form">{children}</div>
<div className="gf-form">
<div className="muted width-17">{data.description}</div>
</div>
</div>
</components.Option>
);
};
export default Option;

View File

@ -1,52 +0,0 @@
import React, { Component } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
import IndicatorsContainer from './IndicatorsContainer';
import ResetStyles from './ResetStyles';
import NoOptionsMessage from './NoOptionsMessage';
export interface OptionWithDescription {
value: any;
label: string;
description: string;
}
export interface Props {
optionsWithDesc: OptionWithDescription[];
onSelected: (permission) => void;
disabled: boolean;
className?: string;
value?: any;
}
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
class DescriptionPicker extends Component<Props, any> {
render() {
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
const selectedOption = getSelectedOption(optionsWithDesc, value);
return (
<div className="permissions-picker">
<Select
placeholder="Choose"
classNamePrefix={`gf-form-select-box`}
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
options={optionsWithDesc}
components={{
Option: DescriptionOption,
IndicatorsContainer,
NoOptionsMessage,
}}
styles={ResetStyles}
isDisabled={disabled}
onChange={onSelected}
getOptionValue={i => i.value}
getOptionLabel={i => i.label}
value={selectedOption}
/>
</div>
);
}
}
export default DescriptionPicker;

View File

@ -1,18 +0,0 @@
import React from 'react';
import { components } from 'react-select';
import { OptionProps } from 'react-select/lib/components/Option';
export interface Props {
children: Element;
}
export const PickerOption = (props: OptionProps<any>) => {
const { children, className } = props;
return (
<components.Option {...props}>
<div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
</components.Option>
);
};
export default PickerOption;

View File

@ -1,22 +0,0 @@
import React from 'react';
import { components } from 'react-select';
import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> {
data: any;
}
export const PickerOption = (props: ExtendedOptionProps) => {
const { children, data, className } = props;
return (
<components.Option {...props}>
<div className={`description-picker-option__button btn btn-link ${className}`}>
{data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
{children}
</div>
</components.Option>
);
};
export default PickerOption;

View File

@ -1,49 +0,0 @@
import React, { SFC } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
import ResetStyles from './ResetStyles';
interface Props {
className?: string;
defaultValue?: any;
getOptionLabel: (item: any) => string;
getOptionValue: (item: any) => string;
onSelected: (item: any) => {} | void;
options: any[];
placeholder?: string;
width: number;
value: any;
}
const SimplePicker: SFC<Props> = ({
className,
defaultValue,
getOptionLabel,
getOptionValue,
onSelected,
options,
placeholder,
width,
value,
}) => {
return (
<Select
classNamePrefix={`gf-form-select-box`}
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
components={{
Option: DescriptionOption,
}}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
isSearchable={false}
onChange={onSelected}
options={options}
placeholder={placeholder || 'Choose'}
styles={ResetStyles}
/>
);
};
export default SimplePicker;

View File

@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PickerOption renders correctly 1`] = `
<div>
<div
className="description-picker-option__button btn btn-link class-for-user-picker"
>
<img
alt="User picker label"
className="user-picker-option__avatar"
src="url/to/avatar"
/>
Model title
</div>
</div>
`;

View File

@ -0,0 +1,35 @@
import { PureComponent } from 'react';
import ReactDOM from 'react-dom';
interface Props {
className?: string;
root?: HTMLElement;
}
export default class BodyPortal extends PureComponent<Props> {
node: HTMLElement = document.createElement('div');
portalRoot: HTMLElement;
constructor(props) {
super(props);
const {
className,
root = document.body
} = this.props;
if (className) {
this.node.classList.add(className);
}
this.portalRoot = root;
this.portalRoot.appendChild(this.node);
}
componentWillUnmount() {
this.portalRoot.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}

View File

@ -0,0 +1,72 @@
// Libraries
import React, { PureComponent } from 'react';
import _ from 'lodash';
// Components
import Select from './Select';
// Types
import { DataSourceSelectItem } from 'app/types';
export interface Props {
onChange: (ds: DataSourceSelectItem) => void;
datasources: DataSourceSelectItem[];
current: DataSourceSelectItem;
onBlur?: () => void;
autoFocus?: boolean;
}
export class DataSourcePicker extends PureComponent<Props> {
static defaultProps = {
autoFocus: false,
};
searchInput: HTMLElement;
constructor(props) {
super(props);
}
onChange = item => {
const ds = this.props.datasources.find(ds => ds.name === item.value);
this.props.onChange(ds);
};
render() {
const { datasources, current, autoFocus, onBlur } = this.props;
const options = datasources.map(ds => ({
value: ds.name,
label: ds.name,
imgUrl: ds.meta.info.logos.small,
}));
const value = current && {
label: current.name,
value: current.name,
imgUrl: current.meta.info.logos.small,
};
return (
<div className="gf-form-inline">
<Select
className="ds-picker"
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={this.onChange}
options={options}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={true}
maxMenuHeight={500}
placeholder="Select datasource"
noOptionsMessage={() => 'No datasources found'}
value={value}
/>
</div>
);
}
}
export default DataSourcePicker;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { components } from 'react-select';
import React from 'react';
import { components } from '@torkelo/react-select';
export const IndicatorsContainer = props => {
const isOpen = props.selectProps.menuIsOpen;

View File

@ -0,0 +1,20 @@
import React from 'react';
import { components } from '@torkelo/react-select';
import { OptionProps } from '@torkelo/react-select/lib/components/Option';
export interface Props {
children: Element;
}
export const NoOptionsMessage = (props: OptionProps<any>) => {
const { children } = props;
return (
<components.Option {...props}>
<div className="gf-form-select-box__desc-option">
<div className="gf-form-select-box__desc-option__body">{children}</div>
</div>
</components.Option>
);
};
export default NoOptionsMessage;

View File

@ -0,0 +1,53 @@
import React, { PureComponent } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
interface ExtendedGroupProps extends GroupProps<any> {
data: any;
}
interface State {
expanded: boolean;
}
export default class OptionGroup extends PureComponent<ExtendedGroupProps, State> {
state = {
expanded: false,
};
componentDidMount() {
if (this.props.selectProps) {
const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
if (value && this.props.options.some(option => option.value === value)) {
this.setState({ expanded: true });
}
}
}
componentDidUpdate(nextProps) {
if (nextProps.selectProps.inputValue !== '') {
this.setState({ expanded: true });
}
}
onToggleChildren = () => {
this.setState(prevState => ({
expanded: !prevState.expanded,
}));
};
render() {
const { children, label } = this.props;
const { expanded } = this.state;
return (
<div className="gf-form-select-box__option-group">
<div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
<span className="flex-grow">{label}</span>
<i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
</div>
{expanded && children}
</div>
);
}
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import renderer from 'react-test-renderer';
import PickerOption from './PickerOption';
@ -24,7 +24,7 @@ const model = {
children: 'Model title',
data: {
title: 'Model title',
avatarUrl: 'url/to/avatar',
imgUrl: 'url/to/avatar',
label: 'User picker label',
},
className: 'class-for-user-picker',

View File

@ -0,0 +1,44 @@
import React from 'react';
import { components } from '@torkelo/react-select';
import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> {
data: {
description?: string;
imgUrl?: string;
};
}
export const Option = (props: ExtendedOptionProps) => {
const { children, isSelected, data } = props;
return (
<components.Option {...props}>
<div className="gf-form-select-box__desc-option">
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
<div className="gf-form-select-box__desc-option__body">
<div>{children}</div>
{data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>}
</div>
{isSelected && <i className="fa fa-check" aria-hidden="true" />}
</div>
</components.Option>
);
};
// was not able to type this without typescript error
export const SingleValue = props => {
const { children, data } = props;
return (
<components.SingleValue {...props}>
<div className="gf-form-select-box__img-value">
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
{children}
</div>
</components.SingleValue>
);
};
export default Option;

View File

@ -1,4 +1,4 @@
export default {
export default {
clearIndicator: () => ({}),
container: () => ({}),
control: () => ({}),
@ -11,7 +11,9 @@
loadingIndicator: () => ({}),
loadingMessage: () => ({}),
menu: () => ({}),
menuList: () => ({}),
menuList: ({ maxHeight }: { maxHeight: number }) => ({
maxHeight,
}),
multiValue: () => ({}),
multiValueLabel: () => ({}),
multiValueRemove: () => ({}),

View File

@ -0,0 +1,232 @@
// Libraries
import classNames from 'classnames';
import React, { PureComponent } from 'react';
import { default as ReactSelect } from '@torkelo/react-select';
import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
import { components } from '@torkelo/react-select';
// Components
import { Option, SingleValue } from './PickerOption';
import OptionGroup from './OptionGroup';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
import ResetStyles from './ResetStyles';
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
export interface SelectOptionItem {
label?: string;
value?: any;
imgUrl?: string;
description?: string;
[key: string]: any;
}
interface CommonProps {
defaultValue?: any;
getOptionLabel?: (item: SelectOptionItem) => string;
getOptionValue?: (item: SelectOptionItem) => string;
onChange: (item: SelectOptionItem) => {} | void;
placeholder?: string;
width?: number;
value?: SelectOptionItem;
className?: string;
isDisabled?: boolean;
isSearchable?: boolean;
isClearable?: boolean;
autoFocus?: boolean;
openMenuOnFocus?: boolean;
onBlur?: () => void;
maxMenuHeight?: number;
isLoading: boolean;
noOptionsMessage?: () => string;
isMulti?: boolean;
backspaceRemovesValue: boolean;
}
interface SelectProps {
options: SelectOptionItem[];
}
interface AsyncProps {
defaultOptions: boolean;
loadOptions: (query: string) => Promise<SelectOptionItem[]>;
loadingMessage?: () => string;
}
export const MenuList = props => {
return (
<components.MenuList {...props}>
<CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
</components.MenuList>
);
};
export class Select extends PureComponent<CommonProps & SelectProps> {
static defaultProps = {
width: null,
className: '',
isDisabled: false,
isSearchable: true,
isClearable: false,
isMulti: false,
openMenuOnFocus: false,
autoFocus: false,
isLoading: false,
backspaceRemovesValue: true,
maxMenuHeight: 300,
};
render() {
const {
defaultValue,
getOptionLabel,
getOptionValue,
onChange,
options,
placeholder,
width,
value,
className,
isDisabled,
isLoading,
isSearchable,
isClearable,
backspaceRemovesValue,
isMulti,
autoFocus,
openMenuOnFocus,
onBlur,
maxMenuHeight,
noOptionsMessage,
} = this.props;
let widthClass = '';
if (width) {
widthClass = 'width-' + width;
}
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return (
<ReactSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
Option,
SingleValue,
IndicatorsContainer,
MenuList,
Group: OptionGroup,
}}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
isSearchable={isSearchable}
onChange={onChange}
options={options}
placeholder={placeholder || 'Choose'}
styles={ResetStyles}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
noOptionsMessage={noOptionsMessage}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>
);
}
}
export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
static defaultProps = {
width: null,
className: '',
components: {},
loadingMessage: () => 'Loading...',
isDisabled: false,
isClearable: false,
isMulti: false,
isSearchable: true,
backspaceRemovesValue: true,
autoFocus: false,
openMenuOnFocus: false,
maxMenuHeight: 300,
};
render() {
const {
defaultValue,
getOptionLabel,
getOptionValue,
onChange,
placeholder,
width,
value,
className,
loadOptions,
defaultOptions,
isLoading,
loadingMessage,
noOptionsMessage,
isDisabled,
isSearchable,
isClearable,
backspaceRemovesValue,
autoFocus,
onBlur,
openMenuOnFocus,
maxMenuHeight,
isMulti,
} = this.props;
let widthClass = '';
if (width) {
widthClass = 'width-' + width;
}
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return (
<ReactAsyncSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
Option,
SingleValue,
IndicatorsContainer,
NoOptionsMessage,
}}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
onChange={onChange}
loadOptions={loadOptions}
isLoading={isLoading}
defaultOptions={defaultOptions}
placeholder={placeholder || 'Choose'}
styles={ResetStyles}
loadingMessage={loadingMessage}
noOptionsMessage={noOptionsMessage}
isDisabled={isDisabled}
isSearchable={isSearchable}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>
);
}
}
export default Select;

View File

@ -1,11 +1,7 @@
import React, { Component } from 'react';
import AsyncSelect from 'react-select/lib/Async';
import PickerOption from './PickerOption';
import { AsyncSelect } from './Select';
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
import ResetStyles from './ResetStyles';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
export interface Team {
id: number;
@ -45,9 +41,10 @@ export class TeamPicker extends Component<Props, State> {
const teams = result.teams.map(team => {
return {
id: team.id,
value: team.id,
label: team.name,
name: team.name,
avatarUrl: team.avatarUrl,
imgUrl: team.avatarUrl,
};
});
@ -62,24 +59,13 @@ export class TeamPicker extends Component<Props, State> {
return (
<div className="user-picker">
<AsyncSelect
classNamePrefix={`gf-form-select-box`}
isMulti={false}
isLoading={isLoading}
defaultOptions={true}
loadOptions={this.debouncedSearch}
onChange={onSelected}
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
styles={ResetStyles}
components={{
Option: PickerOption,
IndicatorsContainer,
NoOptionsMessage,
}}
className={className}
placeholder="Select a team"
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'No teams found'}
getOptionValue={i => i.id}
getOptionLabel={i => i.label}
/>
</div>
);

View File

@ -0,0 +1,51 @@
import React, { PureComponent } from 'react';
import Select from './Select';
import kbn from 'app/core/utils/kbn';
interface Props {
onChange: (item: any) => {} | void;
defaultValue?: string;
width?: number;
}
export default class UnitPicker extends PureComponent<Props> {
static defaultProps = {
width: 12,
};
render() {
const { defaultValue, onChange, width } = this.props;
const unitGroups = kbn.getUnitFormats();
// Need to transform the data structure to work well with Select
const groupOptions = unitGroups.map(group => {
const options = group.submenu.map(unit => {
return {
label: unit.text,
value: unit.value,
};
});
return {
label: group.text,
options,
};
});
const value = groupOptions.map(group => {
return group.options.find(option => option.value === defaultValue);
});
return (
<Select
width={width}
defaultValue={value}
isSearchable={true}
options={groupOptions}
placeholder="Choose"
onChange={onChange}
/>
);
}
}

View File

@ -1,12 +1,15 @@
// Libraries
import React, { Component } from 'react';
import AsyncSelect from 'react-select/lib/Async';
import PickerOption from './PickerOption';
// Components
import { AsyncSelect } from './Select';
// Utils & Services
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
// Types
import { User } from 'app/types';
import ResetStyles from './ResetStyles';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
export interface Props {
onSelected: (user: User) => void;
@ -40,8 +43,9 @@ export class UserPicker extends Component<Props, State> {
.then(result => {
return result.map(user => ({
id: user.userId,
value: user.userId,
label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
imgUrl: user.avatarUrl,
login: user.login,
}));
})
@ -57,24 +61,13 @@ export class UserPicker extends Component<Props, State> {
return (
<div className="user-picker">
<AsyncSelect
classNamePrefix={`gf-form-select-box`}
isMulti={false}
className={className}
isLoading={isLoading}
defaultOptions={true}
loadOptions={this.debouncedSearch}
onChange={onSelected}
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
styles={ResetStyles}
components={{
Option: PickerOption,
IndicatorsContainer,
NoOptionsMessage,
}}
placeholder="Select user"
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'No users found'}
getOptionValue={i => i.id}
getOptionLabel={i => i.label}
/>
</div>
);

View File

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PickerOption renders correctly 1`] = `
<div>
<div
className="gf-form-select-box__desc-option"
>
<img
className="gf-form-select-box__desc-option__img"
src="url/to/avatar"
/>
<div
className="gf-form-select-box__desc-option__body"
>
<div>
Model title
</div>
</div>
</div>
</div>
`;

View File

@ -57,35 +57,6 @@ exports[`TeamPicker renders correctly 1`] = `
}
}
tabIndex="0"
theme={
Object {
"borderRadius": 4,
"colors": Object {
"danger": "#DE350B",
"dangerLight": "#FFBDAD",
"neutral0": "hsl(0, 0%, 100%)",
"neutral10": "hsl(0, 0%, 90%)",
"neutral20": "hsl(0, 0%, 80%)",
"neutral30": "hsl(0, 0%, 70%)",
"neutral40": "hsl(0, 0%, 60%)",
"neutral5": "hsl(0, 0%, 95%)",
"neutral50": "hsl(0, 0%, 50%)",
"neutral60": "hsl(0, 0%, 40%)",
"neutral70": "hsl(0, 0%, 30%)",
"neutral80": "hsl(0, 0%, 20%)",
"neutral90": "hsl(0, 0%, 10%)",
"primary": "#2684FF",
"primary25": "#DEEBFF",
"primary50": "#B2D4FF",
"primary75": "#4C9AFF",
},
"spacing": Object {
"baseUnit": 4,
"controlHeight": 38,
"menuGutter": 8,
},
}
}
type="text"
value=""
/>

View File

@ -57,35 +57,6 @@ exports[`UserPicker renders correctly 1`] = `
}
}
tabIndex="0"
theme={
Object {
"borderRadius": 4,
"colors": Object {
"danger": "#DE350B",
"dangerLight": "#FFBDAD",
"neutral0": "hsl(0, 0%, 100%)",
"neutral10": "hsl(0, 0%, 90%)",
"neutral20": "hsl(0, 0%, 80%)",
"neutral30": "hsl(0, 0%, 70%)",
"neutral40": "hsl(0, 0%, 60%)",
"neutral5": "hsl(0, 0%, 95%)",
"neutral50": "hsl(0, 0%, 50%)",
"neutral60": "hsl(0, 0%, 40%)",
"neutral70": "hsl(0, 0%, 30%)",
"neutral80": "hsl(0, 0%, 20%)",
"neutral90": "hsl(0, 0%, 10%)",
"primary": "#2684FF",
"primary25": "#DEEBFF",
"primary50": "#B2D4FF",
"primary75": "#4C9AFF",
},
"spacing": Object {
"baseUnit": 4,
"controlHeight": 38,
"menuGutter": 8,
},
}
}
type="text"
value=""
/>

View File

@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { Label } from 'app/core/components/Label/Label';
import SimplePicker from 'app/core/components/Picker/SimplePicker';
import Select from 'app/core/components/Select/Select';
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit } from 'app/types';
@ -17,12 +17,12 @@ export interface State {
dashboards: DashboardSearchHit[];
}
const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
const themes = [{ value: '', label: 'Default' }, { value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }];
const timezones = [
{ value: '', text: 'Default' },
{ value: 'browser', text: 'Local browser time' },
{ value: 'utc', text: 'UTC' },
{ value: '', label: 'Default' },
{ value: 'browser', label: 'Local browser time' },
{ value: 'utc', label: 'UTC' },
];
export class SharedPreferences extends PureComponent<Props, State> {
@ -91,12 +91,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
<h3 className="page-heading">Preferences</h3>
<div className="gf-form">
<span className="gf-form-label width-11">UI Theme</span>
<SimplePicker
<Select
isSearchable={false}
value={themes.find(item => item.value === theme)}
options={themes}
getOptionValue={i => i.value}
getOptionLabel={i => i.text}
onSelected={theme => this.onThemeChanged(theme.value)}
onChange={theme => this.onThemeChanged(theme.value)}
width={20}
/>
</div>
@ -107,11 +106,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
>
Home Dashboard
</Label>
<SimplePicker
<Select
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
getOptionValue={i => i.id}
getOptionLabel={i => i.title}
onSelected={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
onChange={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
options={dashboards}
placeholder="Chose default dashboard"
width={20}
@ -119,11 +118,10 @@ export class SharedPreferences extends PureComponent<Props, State> {
</div>
<div className="gf-form">
<label className="gf-form-label width-11">Timezone</label>
<SimplePicker
<Select
isSearchable={false}
value={timezones.find(item => item.value === timezone)}
getOptionValue={i => i.value}
getOptionLabel={i => i.text}
onSelected={timezone => this.onTimeZoneChanged(timezone.value)}
onChange={timezone => this.onTimeZoneChanged(timezone.value)}
options={timezones}
width={20}
/>

View File

@ -5,8 +5,8 @@ export interface Props {
label: string;
checked: boolean;
labelClass?: string;
small?: boolean;
switchClass?: string;
transparent?: boolean;
onChange: (event) => any;
}
@ -25,27 +25,20 @@ export class Switch extends PureComponent<Props, State> {
};
render() {
const { labelClass = '', switchClass = '', label, checked, small } = this.props;
const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
const labelId = `check-${this.state.id}`;
let labelClassName = `gf-form-label ${labelClass} pointer`;
let switchClassName = `gf-form-switch ${switchClass}`;
if (small) {
labelClassName += ' gf-form-label--small';
switchClassName += ' gf-form-switch--small';
}
const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
return (
<div className="gf-form">
{label && (
<label htmlFor={labelId} className={labelClassName}>
{label}
</label>
)}
<label htmlFor={labelId} className="gf-form-switch-container">
{label && <div className={labelClassName}>{label}</div>}
<div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
<label htmlFor={labelId} />
<span className="gf-form-switch__slider" />
</div>
</div>
</label>
);
}
}

View File

@ -1,11 +1,12 @@
import React from 'react';
import AsyncSelect from 'react-select/lib/Async';
import AsyncSelect from '@torkelo/react-select/lib/Async';
import { TagOption } from './TagOption';
import { TagBadge } from './TagBadge';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
import { components } from 'react-select';
import ResetStyles from 'app/core/components/Picker/ResetStyles';
import IndicatorsContainer from 'app/core/components/Select/IndicatorsContainer';
import NoOptionsMessage from 'app/core/components/Select/NoOptionsMessage';
import { components } from '@torkelo/react-select';
import ResetStyles from 'app/core/components/Select/ResetStyles';
export interface Props {
tags: string[];

View File

@ -1,5 +1,5 @@
import React from 'react';
import { components } from 'react-select';
import { components } from '@torkelo/react-select';
import { OptionProps } from 'react-select/lib/components/Option';
import { TagBadge } from './TagBadge';

View File

@ -1,43 +1,20 @@
import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react';
import React, { SFC, ReactNode, PureComponent } from 'react';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
interface ToggleButtonGroupProps {
onChange: (value) => void;
value?: any;
label?: string;
render: (props) => void;
children: JSX.Element[];
transparent?: boolean;
}
export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
getValues() {
const { children } = this.props;
return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
}
smallChildren() {
const { children } = this.props;
return React.Children.toArray(children).every((c: ReactElement<any>) => c.props.className.includes('small'));
}
handleToggle(toggleValue) {
const { value, onChange } = this.props;
if (value && value === toggleValue) {
return;
}
onChange(toggleValue);
}
render() {
const { value, label } = this.props;
const values = this.getValues();
const selectedValue = value || values[0];
const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`;
const { children, label, transparent } = this.props;
return (
<div className="gf-form">
<div className="toggle-button-group">
{label && <label className={labelClassName}>{label}</label>}
{this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })}
</div>
{label && <label className={`gf-form-label ${transparent ? 'gf-form-label--transparent' : ''}`}>{label}</label>}
<div className={`toggle-button-group ${transparent ? 'toggle-button-group--transparent' : ''}`}>{children}</div>
</div>
);
}
@ -49,15 +26,15 @@ interface ToggleButtonProps {
value: any;
className?: string;
children: ReactNode;
title?: string;
tooltip?: string;
}
export const ToggleButton: SFC<ToggleButtonProps> = ({
children,
selected,
className = '',
title = null,
value,
value = null,
tooltip,
onChange,
}) => {
const handleChange = event => {
@ -68,9 +45,15 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
};
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
return (
<button className={btnClassName} onClick={handleChange} title={title}>
const button = (
<button className={btnClassName} onClick={handleChange}>
<span>{children}</span>
</button>
);
if (tooltip) {
return <Tooltip content={tooltip}>{button}</Tooltip>;
} else {
return button;
}
};

View File

@ -1,34 +1,19 @@
import React from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
interface PopoverProps {
tooltipSetState: (prevState: object) => void;
}
class Popover extends React.Component<PopoverProps, any> {
constructor(props) {
super(props);
this.toggleTooltip = this.toggleTooltip.bind(this);
}
toggleTooltip() {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: !prevState.show,
};
});
}
import React, { PureComponent } from 'react';
import Popper from './Popper';
import withPopper, { UsingPopperProps } from './withPopper';
class Popover extends PureComponent<UsingPopperProps> {
render() {
const { children, hidePopper, showPopper, className, ...restProps } = this.props;
const togglePopper = restProps.show ? hidePopper : showPopper;
return (
<Target className="popper__target" onClick={this.toggleTooltip}>
{this.props.children}
</Target>
<div className={`popper__manager ${className}`} onClick={togglePopper}>
<Popper {...restProps}>{children}</Popper>
</div>
);
}
}
export default withTooltip(Popover);
export default withPopper(Popover);

View File

@ -0,0 +1,72 @@
import React, { PureComponent } from 'react';
import Portal from 'app/core/components/Portal/Portal';
import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
import Transition from 'react-transition-group/Transition';
const defaultTransitionStyles = {
transition: 'opacity 200ms linear',
opacity: 0,
};
const transitionStyles = {
exited: { opacity: 0 },
entering: { opacity: 0 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
};
interface Props {
renderContent: (content: any) => any;
show: boolean;
placement?: any;
content: string | ((props: any) => JSX.Element);
refClassName?: string;
}
class Popper extends PureComponent<Props> {
render() {
const { children, renderContent, show, placement, refClassName } = this.props;
const { content } = this.props;
return (
<Manager>
<Reference>
{({ ref }) => (
<div className={`popper_ref ${refClassName || ''}`} ref={ref}>
{children}
</div>
)}
</Reference>
<Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
{transitionState => (
<Portal>
<ReactPopper placement={placement}>
{({ ref, style, placement, arrowProps }) => {
return (
<div
ref={ref}
style={{
...style,
...defaultTransitionStyles,
...transitionStyles[transitionState],
}}
data-placement={placement}
className="popper"
>
<div className="popper__background">
{renderContent(content)}
<div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
</div>
</div>
);
}}
</ReactPopper>
</Portal>
)}
</Transition>
</Manager>
);
}
}
export default Popper;

View File

@ -1,36 +1,17 @@
import React, { PureComponent } from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
interface Props {
tooltipSetState: (prevState: object) => void;
}
class Tooltip extends PureComponent<Props> {
showTooltip = () => {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => ({
...prevState,
show: true,
}));
};
hideTooltip = () => {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => ({
...prevState,
show: false,
}));
};
import Popper from './Popper';
import withPopper, { UsingPopperProps } from './withPopper';
class Tooltip extends PureComponent<UsingPopperProps> {
render() {
const { children, hidePopper, showPopper, className, ...restProps } = this.props;
return (
<Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
{this.props.children}
</Target>
<div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
<Popper {...restProps}>{children}</Popper>
</div>
);
}
}
export default withTooltip(Tooltip);
export default withPopper(Tooltip);

View File

@ -3,10 +3,10 @@
exports[`Popover renders correctly 1`] = `
<div
className="popper__manager test-class"
onClick={[Function]}
>
<div
className="popper__target"
onClick={[Function]}
className="popper_ref "
>
<button>
Button with Popover

View File

@ -3,11 +3,11 @@
exports[`Tooltip renders correctly 1`] = `
<div
className="popper__manager test-class"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<div
className="popper__target"
onMouseOut={[Function]}
onMouseOver={[Function]}
className="popper_ref "
>
<a
href="http://www.grafana.com"

View File

@ -0,0 +1,88 @@
import React from 'react';
export interface UsingPopperProps {
showPopper: (prevState: object) => void;
hidePopper: (prevState: object) => void;
renderContent: (content: any) => any;
show: boolean;
placement?: string;
content: string | ((props: any) => JSX.Element);
className?: string;
refClassName?: string;
}
interface Props {
placement?: string;
className?: string;
refClassName?: string;
content: string | ((props: any) => JSX.Element);
}
interface State {
placement: string;
show: boolean;
}
export default function withPopper(WrappedComponent) {
return class extends React.Component<Props, State> {
constructor(props) {
super(props);
this.setState = this.setState.bind(this);
this.state = {
placement: this.props.placement || 'auto',
show: false,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.placement && nextProps.placement !== this.state.placement) {
this.setState(prevState => {
return {
...prevState,
placement: nextProps.placement,
};
});
}
}
showPopper = () => {
this.setState(prevState => ({
...prevState,
show: true,
}));
};
hidePopper = () => {
this.setState(prevState => ({
...prevState,
show: false,
}));
};
renderContent(content) {
if (typeof content === 'function') {
// If it's a function we assume it's a React component
const ReactComponent = content;
return <ReactComponent />;
}
return content;
}
render() {
const { show, placement } = this.state;
const className = this.props.className || '';
return (
<WrappedComponent
{...this.props}
showPopper={this.showPopper}
hidePopper={this.hidePopper}
renderContent={this.renderContent}
show={show}
placement={placement}
className={className}
/>
);
}
};
}

View File

@ -1,58 +0,0 @@
import React from 'react';
import { Manager, Popper, Arrow } from 'react-popper';
interface IwithTooltipProps {
placement?: string;
content: string | ((props: any) => JSX.Element);
className?: string;
}
export default function withTooltip(WrappedComponent) {
return class extends React.Component<IwithTooltipProps, any> {
constructor(props) {
super(props);
this.setState = this.setState.bind(this);
this.state = {
placement: this.props.placement || 'auto',
show: false,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.placement && nextProps.placement !== this.state.placement) {
this.setState(prevState => {
return {
...prevState,
placement: nextProps.placement,
};
});
}
}
renderContent(content) {
if (typeof content === 'function') {
// If it's a function we assume it's a React component
const ReactComponent = content;
return <ReactComponent />;
}
return content;
}
render() {
const { content, className } = this.props;
return (
<Manager className={`popper__manager ${className || ''}`}>
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
{this.state.show ? (
<Popper placement={this.state.placement} className="popper">
{this.renderContent(content)}
<Arrow className="popper__arrow" />
</Popper>
) : null}
</Manager>
);
}
};
}

View File

@ -84,7 +84,7 @@ function link(scope, elem, attrs) {
// disable depreacation warning
codeEditor.$blockScrolling = Infinity;
// Padding hacks
(codeEditor.renderer as any).setScrollMargin(15, 15);
(codeEditor.renderer as any).setScrollMargin(10, 10);
codeEditor.renderer.setPadding(10);
setThemeMode();

View File

@ -1,6 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import $ from 'jquery';
import Drop from 'tether-drop';
import { ColorPickerPopover } from './ColorPickerPopover';
import { react2AngularDirective } from 'app/core/utils/react2angular';
@ -11,29 +10,17 @@ export interface Props {
}
export class ColorPicker extends React.Component<Props, any> {
pickerElem: any;
pickerElem: HTMLElement;
colorPickerDrop: any;
constructor(props) {
super(props);
this.openColorPicker = this.openColorPicker.bind(this);
this.closeColorPicker = this.closeColorPicker.bind(this);
this.setPickerElem = this.setPickerElem.bind(this);
this.onColorSelect = this.onColorSelect.bind(this);
}
setPickerElem(elem) {
this.pickerElem = $(elem);
}
openColorPicker() {
openColorPicker = () => {
const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
const dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
const drop = new Drop({
target: this.pickerElem[0],
target: this.pickerElem,
content: dropContentElem,
position: 'top center',
classes: 'drop-popover',
@ -48,23 +35,23 @@ export class ColorPicker extends React.Component<Props, any> {
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
}
};
closeColorPicker() {
closeColorPicker = () => {
setTimeout(() => {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
}
}, 100);
}
};
onColorSelect(color) {
onColorSelect = color => {
this.props.onChange(color);
}
};
render() {
return (
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
<div className="sp-preview">
<div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
</div>

View File

@ -64,10 +64,10 @@
<div class="search-results" ng-show="ctrl.sections.length > 0">
<div class="search-results-filter-row">
<gf-form-switch
<gf-form-checkbox
on-change="ctrl.onSelectAllChanged()"
checked="ctrl.selectAllChecked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
switch-class="gf-form-checkbox--transparent"
/>
<div class="search-results-filter-row__filters">
<div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">

View File

@ -1,12 +1,12 @@
<div ng-repeat="section in ctrl.results" class="search-section">
<div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
<div ng-click="ctrl.toggleSelection(section, $event)">
<gf-form-switch
<div ng-click="ctrl.toggleSelection(section, $event)" class="center-vh">
<gf-form-checkbox
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged($event)"
checked="section.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
</gf-form-switch>
switch-class="gf-form-checkbox--transparent">
</gf-form-checkbox>
</div>
<i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span>
@ -21,13 +21,13 @@
<div ng-if="section.expanded">
<a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
<div ng-click="ctrl.toggleSelection(item, $event)">
<gf-form-switch
<div ng-click="ctrl.toggleSelection(item, $event)" class="center-vh">
<gf-form-checkbox
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged()"
checked="item.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
</gf-form-switch>
switch-class="gf-form-checkbox--transparent">
</gf-form-checkbox>
</div>
<span class="search-item__icon">
<i class="gicon mini gicon-dashboard-list"></i>

View File

@ -1,16 +1,33 @@
import coreModule from 'app/core/core_module';
const template = `
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
</info-popover>
<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
</info-popover>
</div>
<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
<span class="gf-form-switch__slider"></span>
</div>
</label>
`;
const checkboxTemplate = `
<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
</info-popover>
</div>
<div class="gf-form-checkbox {{ctrl.switchClass}}" ng-if="ctrl.show">
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
<span class="gf-form-switch__checkbox"></span>
</div>
</label>
<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
<label for="check-{{ctrl.id}}" data-on="Yes" data-off="No"></label>
</div>
`;
export class SwitchCtrl {
@ -51,4 +68,23 @@ export function switchDirective() {
};
}
export function checkboxDirective() {
return {
restrict: 'E',
controller: SwitchCtrl,
controllerAs: 'ctrl',
bindToController: true,
scope: {
checked: '=',
label: '@',
labelClass: '@',
tooltip: '@',
switchClass: '@',
onChange: '&',
},
template: checkboxTemplate,
};
}
coreModule.directive('gfFormSwitch', switchDirective);
coreModule.directive('gfFormCheckbox', checkboxDirective);

View File

@ -11,3 +11,7 @@ export const LS_PANEL_COPY_KEY = 'panel-copy';
export const DASHBOARD_TOOLBAR_HEIGHT = 55;
export const DASHBOARD_TOP_PADDING = 20;
export const PANEL_HEADER_HEIGHT = 27;
export const PANEL_BORDER = 2;
export const PANEL_OPTIONS_KEY_PREFIX = 'options-';

View File

@ -1,3 +1,4 @@
import $ from 'jquery';
import _ from 'lodash';
import coreModule from '../core_module';
@ -5,18 +6,20 @@ import coreModule from '../core_module';
function dashClass($timeout) {
return {
link: ($scope, elem) => {
const body = $('body');
$scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
console.log('view-mode-changed', panel.fullscreen);
if (panel.fullscreen) {
elem.addClass('panel-in-fullscreen');
body.addClass('panel-in-fullscreen');
} else {
$timeout(() => {
elem.removeClass('panel-in-fullscreen');
body.removeClass('panel-in-fullscreen');
});
}
});
elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
body.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
$scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
if (newValue) {

View File

@ -12,7 +12,7 @@ export function dropdownTypeahead($compile) {
const buttonTemplate =
'<a class="gf-form-label tight-form-func dropdown-toggle"' +
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
' data-placement="top"><i class="fa fa-plus"></i></a>';
' ><i class="fa fa-plus"></i></a>';
return {
scope: {
@ -130,7 +130,7 @@ export function dropdownTypeahead2($compile) {
const buttonTemplate =
'<a class="gf-form-input dropdown-toggle"' +
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
' data-placement="top"><i class="fa fa-plus"></i></a>';
' ><i class="fa fa-plus"></i></a>';
return {
scope: {

View File

@ -18,6 +18,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
if (action.payload.partial) {
query = _.defaults(query, state.query);
query = _.omitBy(query, _.isNull);
}
return {

View File

@ -4,6 +4,8 @@ import _ from 'lodash';
export interface AngularComponent {
destroy();
digest();
getScope();
}
export class AngularLoader {
@ -24,6 +26,12 @@ export class AngularLoader {
scope.$destroy();
compiledElem.remove();
},
digest: () => {
scope.$digest();
},
getScope: () => {
return scope;
},
};
}
}

View File

@ -1,6 +1,6 @@
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { store } from 'app/store/configureStore';
import { store } from 'app/store/store';
import locationUtil from 'app/core/utils/location_util';
import { updateLocation } from 'app/core/actions';

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { store } from '../../store/configureStore';
import { store } from '../../store/store';
export function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);

View File

@ -405,7 +405,8 @@ kbn.valueFormats.percentunit = (size, decimals) => {
};
/* Formats the value to hex. Uses float if specified decimals are not 0.
* There are two options, one with 0x, and one without */
* There are two submenu
* , one with 0x, and one without */
kbn.valueFormats.hex = (value, decimals) => {
if (value == null) {

View File

@ -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;
};

View File

@ -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];
};

View File

@ -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';
@ -261,7 +262,7 @@ export class AlertTabCtrl {
this.datasourceSrv.get(datasourceName).then(ds => {
if (!ds.meta.alerting) {
this.error = 'The datasource does not support alerting queries';
} else if (ds.targetContainsTemplate(foundTarget)) {
} else if (ds.targetContainsTemplate && ds.targetContainsTemplate(foundTarget)) {
this.error = 'Template variables are not supported in alert queries';
} else {
this.error = '';
@ -430,3 +431,5 @@ export function alertTab() {
controller: AlertTabCtrl,
};
}
coreModule.directive('alertTab', alertTab);

View File

@ -1,187 +1,191 @@
<div class="edit-tab-with-sidemenu" ng-if="ctrl.alert">
<aside class="edit-sidemenu-aside">
<ul class="edit-sidemenu">
<li ng-class="{active: ctrl.subTabIndex === 0}">
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 1}">
<a ng-click="ctrl.changeTabIndex(1)">
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 2}">
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
</li>
<li>
<a ng-click="ctrl.delete()">Delete</a>
</li>
</ul>
</aside>
<div class="panel-option-section__body" ng-if="ctrl.alert">
<div class="edit-tab-with-sidemenu">
<aside class="edit-sidemenu-aside">
<ul class="edit-sidemenu">
<li ng-class="{active: ctrl.subTabIndex === 0}">
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 1}">
<a ng-click="ctrl.changeTabIndex(1)">
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
</a>
</li>
<li ng-class="{active: ctrl.subTabIndex === 2}">
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
</li>
<li>
<a ng-click="ctrl.delete()">Delete</a>
</li>
</ul>
</aside>
<div class="edit-tab-content">
<div ng-if="ctrl.subTabIndex === 0">
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
<i class="fa fa-warning"></i> {{ctrl.error}}
</div>
<div class="edit-tab-content">
<div ng-if="ctrl.subTabIndex === 0">
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
<i class="fa fa-warning"></i> {{ctrl.error}}
</div>
<div class="gf-form-group">
<h5 class="section-heading">Alert Config</h5>
<div class="gf-form">
<span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Evaluate every</span>
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
</div>
<div class="gf-form max-width-11">
<label class="gf-form-label width-5">For</label>
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
<info-popover mode="right-absolute">
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
</info-popover>
</div>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Alert Config</h5>
<div class="gf-form">
<span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Evaluate every</span>
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
</div>
<div class="gf-form max-width-11">
<label class="gf-form-label width-5">For</label>
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
<info-popover mode="right-absolute">
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
</info-popover>
</div>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Conditions</h5>
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
<div class="gf-form">
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
</query-part-editor>
<span class="gf-form-label query-keyword">OF</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
</query-part-editor>
</div>
<div class="gf-form">
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Conditions</h5>
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
<div class="gf-form">
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
</query-part-editor>
<span class="gf-form-label query-keyword">OF</span>
</div>
<div class="gf-form">
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
</query-part-editor>
</div>
<div class="gf-form">
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
<i class="fa fa-trash"></i>
</a>
</label>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label dropdown">
<a class="pointer dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-plus"></i>
</a>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
</li>
</ul>
</label>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label dropdown">
<a class="pointer dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-plus"></i>
</a>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
</li>
</ul>
</label>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-18">If no data or all values are null</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
</select>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-18">If no data or all values are null</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
</select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-18">If execution error or timeout</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
</select>
</div>
</div>
<div class="gf-form">
<span class="gf-form-label width-18">If execution error or timeout</span>
<span class="gf-form-label query-keyword">SET STATE TO</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
</select>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.test()">
Test Rule
</button>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.test()">
Test Rule
</button>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.testing">
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
</div>
<div class="gf-form-group" ng-if="ctrl.testing">
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
</div>
<div class="gf-form-group" ng-if="ctrl.testResult">
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.testResult">
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
<h5 class="section-heading">Notifications</h5>
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Send to</span>
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
<i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
</span>
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-8">Message</span>
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
</div>
</div>
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
<h5 class="section-heading">Notifications</h5>
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Send to</span>
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
<i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
</span>
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-8">Message</span>
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
</div>
</div>
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
<h5 class="section-heading" style="whitespace: nowrap">
State history <span class="muted small">(last 50 state changes)</span>
</h5>
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
<h5 class="section-heading" style="whitespace: nowrap">
State history <span class="muted small">(last 50 state changes)</span>
</h5>
<div ng-show="ctrl.alertHistory.length === 0">
<br>
<i>No state changes recorded</i>
</div>
<div ng-show="ctrl.alertHistory.length === 0">
<br>
<i>No state changes recorded</i>
</div>
<ol class="alert-rule-list" >
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
<i class="{{al.stateModel.iconClass}}"></i>
</div>
<div class="alert-rule-item__body">
<div class="alert-rule-item__header">
<div class="alert-rule-item__text">
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
</div>
</div>
<span class="alert-list-info">{{al.info}}</span>
</div>
<div class="alert-rule-item__time">
<span>{{al.time}}</span>
</div>
</li>
</ol>
</div>
</div>
<ol class="alert-rule-list" >
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
<i class="{{al.stateModel.iconClass}}"></i>
</div>
<div class="alert-rule-item__body">
<div class="alert-rule-item__header">
<div class="alert-rule-item__text">
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
</div>
</div>
<span class="alert-list-info">{{al.info}}</span>
</div>
<div class="alert-rule-item__time">
<span>{{al.time}}</span>
</div>
</li>
</ol>
</div>
</div>
</div>
</div>
<div class="gf-form-group" ng-if="!ctrl.alert">
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.enable()">
<i class="icon-gf icon-gf-alert"></i>
Create Alert
</button>
</div>
<div class="gf-form-group p-t-4 p-b-4" ng-if="!ctrl.alert">
<div class="empty-list-cta">
<div class="empty-list-cta__title">Panel has no alert rule defined</div>
<button class="empty-list-cta__button btn btn-xlarge btn-success" ng-click="ctrl.enable()">
<i class="icon-gf icon-gf-alert"></i>
Create Alert
</button>
</div>
</div>
</div>

View File

@ -143,7 +143,7 @@ export class DashboardMigrator {
panelUpgrades.push(panel => {
_.each(panel.targets, target => {
if (!target.refId) {
target.refId = this.dashboard.getNextQueryLetter(panel);
target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter();
}
});
});

View File

@ -806,16 +806,6 @@ export class DashboardModel {
return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow();
}
getNextQueryLetter(panel) {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(panel.targets, other => {
return other.refId !== refId;
});
});
}
isTimezoneUtc() {
return this.getTimezone() === 'utc';
}

View File

@ -1,13 +1,12 @@
import React from 'react';
import _ from 'lodash';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import Highlighter from 'react-highlight-words';
import { updateLocation } from 'app/core/actions';
import { store as reduxStore } from 'app/store/store';
export interface AddPanelPanelProps {
panel: PanelModel;
@ -15,64 +14,25 @@ export interface AddPanelPanelProps {
}
export interface AddPanelPanelState {
filter: string;
panelPlugins: any[];
copiedPanelPlugins: any[];
tab: string;
}
export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
private scrollbar: ScrollBar;
constructor(props) {
super(props);
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
this.renderPanelItem = this.renderPanelItem.bind(this);
this.panelSizeChanged = this.panelSizeChanged.bind(this);
this.state = {
panelPlugins: this.getPanelPlugins(''),
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
filter: '',
tab: 'Add',
copiedPanelPlugins: this.getCopiedPanelPlugins(),
};
}
componentDidMount() {
this.props.panel.events.on('panel-size-changed', this.panelSizeChanged);
}
componentWillUnmount() {
this.props.panel.events.off('panel-size-changed', this.panelSizeChanged);
}
panelSizeChanged() {
setTimeout(() => {
this.scrollbar.update();
});
}
getPanelPlugins(filter) {
let panels = _.chain(config.panels)
.filter({ hideFromList: false })
.map(item => item)
.value();
// add special row type
panels.push({ id: 'row', name: 'Row', sort: 8, info: { logos: { small: 'public/img/icn-row.svg' } } });
panels = this.filterPanels(panels, filter);
// add sort by sort property
return _.sortBy(panels, 'sort');
}
getCopiedPanelPlugins(filter) {
getCopiedPanelPlugins() {
const panels = _.chain(config.panels)
.filter({ hideFromList: false })
.map(item => item)
.value();
let copiedPanels = [];
const copiedPanels = [];
const copiedPanelJson = store.get(LS_PANEL_COPY_KEY);
if (copiedPanelJson) {
@ -86,13 +46,52 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
copiedPanels.push(pluginCopy);
}
}
copiedPanels = this.filterPanels(copiedPanels, filter);
return _.sortBy(copiedPanels, 'sort');
}
onAddPanel = panelPluginInfo => {
handleCloseAddPanel(evt) {
evt.preventDefault();
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
}
copyButton(panel) {
return (
<button className="btn-inverse btn" onClick={() => this.onPasteCopiedPanel(panel)} title={panel.name}>
Paste copied Panel
</button>
);
}
moveToEdit(panel) {
reduxStore.dispatch(
updateLocation({
query: {
panelId: panel.id,
edit: true,
fullscreen: true,
},
partial: true,
})
);
}
onCreateNewPanel = () => {
const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
const newPanel: any = {
type: 'graph',
title: 'Panel Title',
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
};
dashboard.addPanel(newPanel);
dashboard.removePanel(this.props.panel);
this.moveToEdit(newPanel);
};
onPasteCopiedPanel = panelPluginInfo => {
const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
@ -102,16 +101,9 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
};
if (panelPluginInfo.id === 'row') {
newPanel.title = 'Row title';
newPanel.gridPos = { x: 0, y: 0 };
}
// apply panel template / defaults
if (panelPluginInfo.defaults) {
_.defaults(newPanel, panelPluginInfo.defaults);
newPanel.gridPos.w = panelPluginInfo.defaults.gridPos.w;
newPanel.gridPos.h = panelPluginInfo.defaults.gridPos.h;
newPanel.title = panelPluginInfo.defaults.title;
store.delete(LS_PANEL_COPY_KEY);
}
@ -120,133 +112,44 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
dashboard.removePanel(this.props.panel);
};
handleCloseAddPanel(evt) {
evt.preventDefault();
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
}
onCreateNewRow = () => {
const dashboard = this.props.dashboard;
renderText(text: string) {
const searchWords = this.state.filter.split('');
return <Highlighter highlightClassName="highlight-search-match" textToHighlight={text} searchWords={searchWords} />;
}
const newRow: any = {
type: 'row',
title: 'Row title',
gridPos: { x: 0, y: 0 },
};
renderPanelItem(panel, index) {
return (
<div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
<img className="add-panel__item-img" src={panel.info.logos.small} />
<div className="add-panel__item-name">{this.renderText(panel.name)}</div>
</div>
);
}
noCopiedPanelPlugins() {
return <div className="add-panel__no-panels">No copied panels yet.</div>;
}
filterChange(evt) {
this.setState({
filter: evt.target.value,
panelPlugins: this.getPanelPlugins(evt.target.value),
copiedPanelPlugins: this.getCopiedPanelPlugins(evt.target.value),
});
}
filterKeyPress(evt) {
if (evt.key === 'Enter') {
const panel = _.head(this.state.panelPlugins);
if (panel) {
this.onAddPanel(panel);
}
}
}
filterPanels(panels, filter) {
const regex = new RegExp(filter, 'i');
return panels.filter(panel => {
return regex.test(panel.name);
});
}
openCopy() {
this.setState({
tab: 'Copy',
filter: '',
panelPlugins: this.getPanelPlugins(''),
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
});
}
openAdd() {
this.setState({
tab: 'Add',
filter: '',
panelPlugins: this.getPanelPlugins(''),
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
});
}
dashboard.addPanel(newRow);
dashboard.removePanel(this.props.panel);
};
render() {
const addClass = classNames({
'active active--panel': this.state.tab === 'Add',
'': this.state.tab === 'Copy',
});
let addCopyButton;
const copyClass = classNames({
'': this.state.tab === 'Add',
'active active--panel': this.state.tab === 'Copy',
});
let panelTab;
if (this.state.tab === 'Add') {
panelTab = this.state.panelPlugins.map(this.renderPanelItem);
} else if (this.state.tab === 'Copy') {
if (this.state.copiedPanelPlugins.length > 0) {
panelTab = this.state.copiedPanelPlugins.map(this.renderPanelItem);
} else {
panelTab = this.noCopiedPanelPlugins();
}
if (this.state.copiedPanelPlugins.length === 1) {
addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
}
return (
<div className="panel-container add-panel-container">
<div className="add-panel">
<div className="add-panel__header">
<div className="add-panel__header grid-drag-handle">
<i className="gicon gicon-add-panel" />
<span className="add-panel__title">New Panel</span>
<ul className="gf-tabs">
<li className="gf-tabs-item">
<div className={'gf-tabs-link pointer ' + addClass} onClick={this.openAdd.bind(this)}>
Add
</div>
</li>
<li className="gf-tabs-item">
<div className={'gf-tabs-link pointer ' + copyClass} onClick={this.openCopy.bind(this)}>
Paste
</div>
</li>
</ul>
<button className="add-panel__close" onClick={this.handleCloseAddPanel}>
<i className="fa fa-close" />
</button>
</div>
<ScrollBar ref={element => (this.scrollbar = element)} className="add-panel__items">
<div className="add-panel__searchbar">
<label className="gf-form gf-form--grow gf-form--has-input-icon">
<input
type="text"
autoFocus
className="gf-form-input gf-form--grow"
placeholder="Panel Search Filter"
value={this.state.filter}
onChange={this.filterChange.bind(this)}
onKeyPress={this.filterKeyPress.bind(this)}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
{panelTab}
</ScrollBar>
<div className="add-panel-btn-container">
<button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
Edit Panel
</button>
{addCopyButton}
<button className="btn-inverse btn" onClick={this.onCreateNewRow}>
Add Row
</button>
</div>
</div>
</div>
);

View File

@ -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<Props> {
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 = '<alert-tab />';
const scopeProps = {
ctrl: panelCtrl,
};
this.component = loader.load(this.element, scopeProps, template);
}
render() {
return (
<EditorTabBody heading="Alert" toolbarItems={[]}>
<div ref={element => (this.element = element)} />
</EditorTabBody>
);
}
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import ReactGridLayout from 'react-grid-layout';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
@ -8,6 +9,7 @@ import classNames from 'classnames';
import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
function GridWrapper({
size,
@ -24,8 +26,12 @@ function GridWrapper({
isFullscreen,
}) {
const width = size.width > 0 ? size.width : lastGridWidth;
// logic to ignore width changes (optimization)
if (width !== lastGridWidth) {
if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
if (ignoreNextWidthChange) {
ignoreNextWidthChange = false;
} else if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
onWidthChange();
lastGridWidth = width;
}
@ -39,7 +45,7 @@ function GridWrapper({
isResizable={isResizable}
measureBeforeMount={false}
containerPadding={[0, 0]}
useCSSTransforms={true}
useCSSTransforms={false}
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
cols={GRID_COLUMN_COUNT}
rowHeight={GRID_CELL_HEIGHT}
@ -61,7 +67,7 @@ export interface DashboardGridProps {
dashboard: DashboardModel;
}
export class DashboardGrid extends React.Component<DashboardGridProps, any> {
export class DashboardGrid extends React.Component<DashboardGridProps> {
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
@ -73,8 +79,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.onDragStop = this.onDragStop.bind(this);
this.onWidthChange = this.onWidthChange.bind(this);
this.state = { animated: false };
// subscribe to dashboard events
const dashboard = this.props.dashboard;
dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
@ -138,7 +142,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
}
onViewModeChanged(payload) {
this.setState({ animated: !payload.fullscreen });
ignoreNextWidthChange = true;
this.forceUpdate();
}
updateGridPos(item, layout) {
@ -162,17 +167,11 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.updateGridPos(newItem, layout);
}
componentDidMount() {
setTimeout(() => {
this.setState({ animated: true });
});
}
renderPanels() {
const panelElements = [];
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(
<div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
<DashboardPanel
@ -191,7 +190,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
render() {
return (
<SizedReactLayoutGrid
className={classNames({ layout: true, animated: this.state.animated })}
className={classNames({ layout: true })}
layout={this.buildLayout()}
isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.props.dashboard.meta.canEdit}
@ -207,3 +206,5 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
);
}
}
export default hot(module)(DashboardGrid);

View File

@ -1,4 +1,4 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
import { DashboardGrid } from './DashboardGrid';
import DashboardGrid from './DashboardGrid';
react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);

View File

@ -1,15 +1,20 @@
import React, { PureComponent } from 'react';
import config from 'app/core/config';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import classNames from 'classnames';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { DashboardRow } from './DashboardRow';
import { AddPanelPanel } from './AddPanelPanel';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
import { PluginExports, PanelPlugin } from 'app/types/plugins';
import { AddPanelPanel } from './AddPanelPanel';
import { getPanelPluginNotFound } from './PanelPluginNotFound';
import { DashboardRow } from './DashboardRow';
import { PanelChrome } from './PanelChrome';
import { PanelEditor } from './PanelEditor';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { PanelPlugin } from 'app/types';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
@ -18,28 +23,28 @@ export interface Props {
}
export interface State {
pluginExports: PluginExports;
plugin: PanelPlugin;
angularPanel: AngularComponent;
}
export class DashboardPanel extends PureComponent<Props, State> {
element: any;
angularPanel: AngularComponent;
pluginInfo: any;
element: HTMLElement;
specialPanels = {};
constructor(props) {
super(props);
this.state = {
pluginExports: null,
plugin: null,
angularPanel: null,
};
this.specialPanels['row'] = this.renderRow.bind(this);
this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
}
isSpecial() {
return this.specialPanels[this.props.panel.type];
isSpecial(pluginId: string) {
return this.specialPanels[pluginId];
}
renderRow() {
@ -51,60 +56,60 @@ export class DashboardPanel extends PureComponent<Props, State> {
}
onPluginTypeChanged = (plugin: PanelPlugin) => {
this.props.panel.changeType(plugin.id);
this.loadPlugin();
this.loadPlugin(plugin.id);
};
onAngularPluginTypeChanged = () => {
this.loadPlugin();
};
loadPlugin() {
if (this.isSpecial()) {
async loadPlugin(pluginId: string) {
if (this.isSpecial(pluginId)) {
return;
}
// handle plugin loading & changing of plugin type
if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
this.pluginInfo = config.panels[this.props.panel.type];
const { panel } = this.props;
if (this.pluginInfo.exports) {
this.cleanUpAngularPanel();
this.setState({ pluginExports: this.pluginInfo.exports });
// handle plugin loading & changing of plugin type
if (!this.state.plugin || this.state.plugin.id !== pluginId) {
const plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId);
// remember if this is from an angular panel
const fromAngularPanel = this.state.angularPanel != null;
// unmount angular panel
this.cleanUpAngularPanel();
if (panel.type !== pluginId) {
this.props.panel.changeType(pluginId, fromAngularPanel);
}
if (plugin.exports) {
this.setState({ plugin: plugin, angularPanel: null });
} else {
importPluginModule(this.pluginInfo.module).then(pluginExports => {
this.cleanUpAngularPanel();
// cache plugin exports (saves a promise async cycle next time)
this.pluginInfo.exports = pluginExports;
// update panel state
this.setState({ pluginExports: pluginExports });
});
plugin.exports = await importPluginModule(plugin.module);
this.setState({ plugin: plugin, angularPanel: null });
}
}
}
componentDidMount() {
this.loadPlugin();
this.loadPlugin(this.props.panel.type);
}
componentDidUpdate() {
this.loadPlugin();
// handle angular plugin loading
if (!this.element || this.angularPanel) {
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 };
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;
if (this.state.angularPanel) {
this.state.angularPanel.destroy();
this.element = null;
}
}
@ -112,50 +117,61 @@ export class DashboardPanel extends PureComponent<Props, State> {
this.cleanUpAngularPanel();
}
onMouseEnter = () => {
this.props.dashboard.setPanelFocus(this.props.panel.id);
};
onMouseLeave = () => {
this.props.dashboard.setPanelFocus(0);
};
renderReactPanel() {
const { pluginExports } = 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
const { dashboard, panel } = this.props;
const { plugin } = this.state;
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
}
renderAngularPanel() {
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
}
render() {
const { panel, dashboard, isFullscreen, isEditing } = this.props;
const { plugin, angularPanel } = this.state;
if (this.isSpecial(panel.type)) {
return this.specialPanels[panel.type]();
}
// if we have not loaded plugin exports yet, wait
if (!plugin || !plugin.exports) {
return null;
}
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,
});
return (
<div className={containerClass}>
<div className={panelWrapperClass}>
<PanelChrome
component={pluginExports.PanelComponent}
panel={this.props.panel}
dashboard={this.props.dashboard}
/>
<div className={panelWrapperClass} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{plugin.exports.Panel && this.renderReactPanel()}
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
</div>
{this.props.panel.isEditing && (
<div className="panel-editor-container__editor">
<PanelEditor
panel={this.props.panel}
panelType={this.props.panel.type}
dashboard={this.props.dashboard}
onTypeChanged={this.onPluginTypeChanged}
pluginExports={pluginExports}
/>
</div>
{panel.isEditing && (
<PanelEditor
panel={panel}
plugin={plugin}
dashboard={dashboard}
angularPanel={angularPanel}
onTypeChanged={this.onPluginTypeChanged}
/>
)}
</div>
);
}
render() {
if (this.isSpecial()) {
return this.specialPanels[this.props.panel.type]();
}
if (!this.state.pluginExports) {
return null;
}
if (this.state.pluginExports.PanelComponent) {
return this.renderReactPanel();
}
// legacy angular rendering
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
}
}

View File

@ -2,7 +2,10 @@
import React, { Component } from 'react';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
// Utils
import kbn from 'app/core/utils/kbn';
// Types
import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
@ -19,7 +22,10 @@ export interface Props {
dashboardId?: number;
isVisible?: boolean;
timeRange?: TimeRange;
widthPixels: number;
refreshCounter: number;
minInterval?: string;
maxDataPoints?: number;
children: (r: RenderProps) => JSX.Element;
}
@ -36,6 +42,9 @@ export class DataPanel extends Component<Props, State> {
dashboardId: 1,
};
dataSourceSrv: DatasourceSrv = getDatasourceSrv();
isUnmounted = false;
constructor(props: Props) {
super(props);
@ -49,7 +58,11 @@ export class DataPanel extends Component<Props, State> {
}
componentDidMount() {
console.log('DataPanel mount');
this.issueQueries();
}
componentWillUnmount() {
this.isUnmounted = true;
}
async componentDidUpdate(prevProps: Props) {
@ -61,11 +74,11 @@ export class DataPanel extends Component<Props, State> {
}
hasPropsChanged(prevProps: Props) {
return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
return this.props.refreshCounter !== prevProps.refreshCounter;
}
issueQueries = async () => {
const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
private issueQueries = async () => {
const { isVisible, queries, datasource, panelId, dashboardId, timeRange, widthPixels, maxDataPoints } = this.props;
if (!isVisible) {
return;
@ -79,8 +92,11 @@ export class DataPanel extends Component<Props, State> {
this.setState({ loading: LoadingState.Loading });
try {
const dataSourceSrv = getDatasourceSrv();
const ds = await dataSourceSrv.get(datasource);
const ds = await this.dataSourceSrv.get(datasource);
// TODO interpolate variables
const minInterval = this.props.minInterval || ds.interval;
const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
const queryOptions: DataQueryOptions = {
timezone: 'browser',
@ -88,10 +104,10 @@ export class DataPanel extends Component<Props, State> {
dashboardId: dashboardId,
range: timeRange,
rangeRaw: timeRange.raw,
interval: '1s',
intervalMs: 60000,
interval: intervalRes.interval,
intervalMs: intervalRes.intervalMs,
targets: queries,
maxDataPoints: 500,
maxDataPoints: maxDataPoints || widthPixels,
scopedVars: {},
cacheTimeout: null,
};
@ -100,6 +116,10 @@ export class DataPanel extends Component<Props, State> {
const resp = await ds.query(queryOptions);
console.log('Issuing DataPanel query Resp', resp);
if (this.isUnmounted) {
return;
}
this.setState({
loading: LoadingState.Done,
response: resp,
@ -112,21 +132,26 @@ export class DataPanel extends Component<Props, State> {
};
render() {
const { queries } = this.props;
const { response, loading, isFirstLoad } = this.state;
console.log('data panel render');
const timeSeries = response.data;
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
if (isFirstLoad && loading === LoadingState.Loading) {
return this.renderLoadingSpinner();
}
if (!queries.length) {
return (
<div className="loading">
<p>Loading</p>
<div className="panel-empty">
<p>Add a query to get some data!</p>
</div>
);
}
return (
<>
{this.loadingSpinner}
{this.renderLoadingSpinner()}
{this.props.children({
timeSeries,
loading,
@ -135,12 +160,12 @@ export class DataPanel extends Component<Props, State> {
);
}
private get loadingSpinner(): JSX.Element {
private renderLoadingSpinner(): JSX.Element {
const { loading } = this.state;
if (loading === LoadingState.Loading) {
return (
<div className="panel__loading">
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);

View File

@ -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<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
const dsOption = (
<div className="gf-form gf-form--flex-end">
<label className="gf-form-label">{label}</label>
<input
type="text"
className="gf-form-input width-6"
placeholder={placeholder}
name={name}
spellCheck={false}
onBlur={evt => onChange(evt.target.value)}
/>
</div>
);
return tooltipInfo ? <Tooltip content={tooltipInfo}>{dsOption}</Tooltip> : dsOption;
};
export default DataSourceOptions;

View File

@ -0,0 +1,133 @@
// Libraries
import React, { PureComponent } from 'react';
// Components
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { PanelOptionSection } from './PanelOptionSection';
interface Props {
children: JSX.Element;
heading: string;
renderToolbar?: () => JSX.Element;
toolbarItems?: EditorToolBarView[];
}
export interface EditorToolBarView {
title?: string;
heading?: string;
imgSrc?: string;
icon?: string;
disabled?: boolean;
onClick?: () => void;
render: (closeFunction?: any) => JSX.Element | JSX.Element[];
}
interface State {
openView?: EditorToolBarView;
isOpen: boolean;
fadeIn: boolean;
}
export class EditorTabBody extends PureComponent<Props, State> {
static defaultProps = {
toolbarItems: [],
};
constructor(props) {
super(props);
this.state = {
openView: null,
fadeIn: false,
isOpen: false,
};
}
componentDidMount() {
this.setState({ fadeIn: true });
}
onToggleToolBarView = (item: EditorToolBarView) => {
this.setState({
openView: item,
isOpen: !this.state.isOpen,
});
};
onCloseOpenView = () => {
this.setState({ isOpen: false });
};
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;
}
renderButton(view: EditorToolBarView) {
const onClick = () => {
if (view.onClick) {
view.onClick();
}
this.onToggleToolBarView(view);
};
return (
<div className="nav-buttons" key={view.title + view.icon}>
<button className="btn navbar-button" onClick={onClick} disabled={view.disabled}>
{view.icon && <i className={view.icon} />} {view.title}
</button>
</div>
);
}
renderOpenView(view: EditorToolBarView) {
return (
<PanelOptionSection title={view.title || view.heading} onClose={this.onCloseOpenView}>
{view.render()}
</PanelOptionSection>
);
}
render() {
const { children, renderToolbar, heading, toolbarItems } = this.props;
const { openView, fadeIn, isOpen } = this.state;
return (
<>
<div className="toolbar">
<div className="toolbar__heading">{heading}</div>
{renderToolbar && renderToolbar()}
{toolbarItems.length > 0 && (
<>
<div className="gf-form--grow" />
{toolbarItems.map(item => this.renderButton(item))}
</>
)}
</div>
<div className="panel-editor__scroll">
<CustomScrollbar autoHide={false}>
<div className="panel-editor__content">
<FadeIn in={isOpen} duration={200} unmountOnExit={true}>
{openView && this.renderOpenView(openView)}
</FadeIn>
<FadeIn in={fadeIn} duration={50}>
{children}
</FadeIn>
</div>
</CustomScrollbar>
</div>
</>
);
}
}

View File

@ -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<Props> {
element: any;
component: AngularComponent;
constructor(props) {
super(props);
}
componentDidMount() {
if (!this.element) {
return;
}
const { panel } = this.props;
const loader = getAngularLoader();
const template = '<panel-general-tab />';
const scopeProps = {
ctrl: {
panel: panel,
},
};
this.component = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.component) {
this.component.destroy();
}
}
render() {
return (
<EditorTabBody heading="Panel Options" toolbarItems={[]}>
<div ref={element => (this.element = element)} />
</EditorTabBody>
);
}
}

View File

@ -0,0 +1,71 @@
import React, { KeyboardEvent, Component } from 'react';
interface State {
selected: number;
}
export interface KeyboardNavigationProps {
onKeyDown: (evt: KeyboardEvent<EventTarget>, maxSelectedIndex: number, onEnterAction: () => void) => void;
onMouseEnter: (select: number) => void;
selected: number;
}
interface Props {
render: (injectProps: any) => void;
}
class KeyboardNavigation extends Component<Props, State> {
constructor(props) {
super(props);
this.state = {
selected: 0,
};
}
goToNext = (maxSelectedIndex: number) => {
const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
this.setState({
selected: nextIndex,
});
};
goToPrev = (maxSelectedIndex: number) => {
const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
this.setState({
selected: nextIndex,
});
};
onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
this.goToNext(maxSelectedIndex);
}
if (evt.key === 'ArrowUp') {
evt.preventDefault();
this.goToPrev(maxSelectedIndex);
}
if (evt.key === 'Enter' && onEnterAction) {
onEnterAction();
}
};
onMouseEnter = (mouseEnterIndex: number) => {
this.setState({
selected: mouseEnterIndex,
});
};
render() {
const injectProps = {
onKeyDown: this.onKeyDown,
onMouseEnter: this.onMouseEnter,
selected: this.state.selected,
};
return <>{this.props.render({ ...injectProps })}</>;
}
}
export default KeyboardNavigation;

View File

@ -1,31 +1,39 @@
// Libraries
import React, { ComponentClass, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import { AutoSizer } from 'react-virtualized';
// Services
import { getTimeSrv } from '../time_srv';
import { getTimeSrv, TimeSrv } from '../time_srv';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
import { DataPanel } from './DataPanel';
// Utils
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { TimeRange, PanelProps } from 'app/types';
import { PanelPlugin, TimeRange } from 'app/types';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
component: ComponentClass<PanelProps>;
plugin: PanelPlugin;
}
export interface State {
refreshCounter: number;
renderCounter: number;
timeInfo?: string;
timeRange?: TimeRange;
}
export class PanelChrome extends PureComponent<Props, State> {
timeSrv: TimeSrv = getTimeSrv();
constructor(props) {
super(props);
@ -46,22 +54,25 @@ export class PanelChrome extends PureComponent<Props, State> {
}
onRefresh = () => {
const timeSrv = getTimeSrv();
const timeRange = timeSrv.timeRange();
console.log('onRefresh');
if (!this.isVisible) {
return;
}
this.setState(prevState => ({
...prevState,
const { panel } = this.props;
const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
this.setState({
refreshCounter: this.state.refreshCounter + 1,
timeRange: timeRange,
}));
timeRange: timeData.timeRange,
timeInfo: timeData.timeInfo,
});
};
onRender = () => {
console.log('onRender');
this.setState(prevState => ({
...prevState,
this.setState({
renderCounter: this.state.renderCounter + 1,
}));
});
};
get isVisible() {
@ -69,39 +80,60 @@ export class PanelChrome extends PureComponent<Props, State> {
}
render() {
const { panel, dashboard } = this.props;
const { refreshCounter, timeRange, renderCounter } = this.state;
const { panel, dashboard, plugin } = this.props;
const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
const { datasource, targets } = panel;
const PanelComponent = this.props.component;
const { datasource, targets, transparent } = panel;
const PanelComponent = plugin.exports.Panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
console.log('panelChrome render');
return (
<div className="panel-container">
<PanelHeader panel={panel} dashboard={dashboard} />
<div className="panel-content">
<DataPanel
datasource={datasource}
queries={targets}
timeRange={timeRange}
isVisible={this.isVisible}
refreshCounter={refreshCounter}
>
{({ loading, timeSeries }) => {
console.log('panelcrome inner render');
return (
<PanelComponent
loading={loading}
timeSeries={timeSeries}
timeRange={timeRange}
options={panel.getOptions()}
renderCounter={renderCounter}
/>
);
}}
</DataPanel>
</div>
</div>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
return null;
}
return (
<div className={containerClassNames}>
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={timeInfo}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
/>
<DataPanel
datasource={datasource}
queries={targets}
timeRange={timeRange}
isVisible={this.isVisible}
widthPixels={width}
refreshCounter={refreshCounter}
>
{({ loading, timeSeries }) => {
return (
<div className="panel-content">
<PanelComponent
loading={loading}
timeSeries={timeSeries}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
width={width}
height={height - PANEL_HEADER_HEIGHT}
renderCounter={renderCounter}
/>
</div>
);
}}
</DataPanel>
</div>
);
}}
</AutoSizer>
);
}
}

View File

@ -2,73 +2,37 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { QueriesTab } from './QueriesTab';
import { VizTypePicker } from './VizTypePicker';
import { VisualizationTab } from './VisualizationTab';
import { GeneralTab } from './GeneralTab';
import { AlertTab } from './AlertTab';
import { store } from 'app/store/configureStore';
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, PluginExports } from 'app/types/plugins';
import { PanelPlugin } from 'app/types/plugins';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
interface PanelEditorProps {
panel: PanelModel;
dashboard: DashboardModel;
panelType: string;
pluginExports: PluginExports;
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onTypeChanged: (newType: PanelPlugin) => void;
}
interface PanelEditorTab {
id: string;
text: string;
icon: string;
}
export class PanelEditor extends PureComponent<PanelEditorProps> {
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' },
];
}
renderQueriesTab() {
return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
}
renderPanelOptions() {
const { pluginExports, panel } = this.props;
if (pluginExports.PanelOptionsComponent) {
const OptionsComponent = pluginExports.PanelOptionsComponent;
return <OptionsComponent options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
} else {
return <p>Visualization has no options</p>;
}
}
onPanelOptionsChanged = (options: any) => {
this.props.panel.updateOptions(options);
this.forceUpdate();
};
renderVizTab() {
return (
<div className="viz-editor">
<div className="viz-editor-col1">
<VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
</div>
<div className="viz-editor-col2">
<h5 className="page-heading">Options</h5>
{this.renderPanelOptions()}
</div>
</div>
);
}
onChangeTab = (tab: PanelEditorTab) => {
@ -81,28 +45,79 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
this.forceUpdate();
};
renderCurrentTab(activeTab: string) {
const { panel, dashboard, onTypeChanged, plugin, angularPanel } = this.props;
switch (activeTab) {
case 'advanced':
return <GeneralTab panel={panel} />;
case 'queries':
return <QueriesTab panel={panel} dashboard={dashboard} />;
case 'alert':
return <AlertTab angularPanel={angularPanel} />;
case 'visualization':
return (
<VisualizationTab
panel={panel}
dashboard={dashboard}
plugin={plugin}
onTypeChanged={onTypeChanged}
angularPanel={angularPanel}
/>
);
default:
return null;
}
}
render() {
const { location } = store.getState();
const activeTab = location.query.tab || 'queries';
const { plugin } = this.props;
let activeTab = store.getState().location.query.tab || 'queries';
const tabs: PanelEditorTab[] = [
{ id: 'queries', text: 'Queries' },
{ id: 'visualization', text: 'Visualization' },
{ id: 'advanced', text: 'Panel Options' },
];
// handle panels that do not have queries tab
if (plugin.exports.PanelCtrl) {
if (!plugin.exports.PanelCtrl.prototype.onDataReceived) {
// remove queries tab
tabs.shift();
// switch tab
if (activeTab === 'queries') {
activeTab = 'visualization';
}
}
}
if (config.alertingEnabled && plugin.id === 'graph') {
tabs.push({
id: 'alert',
text: 'Alert',
});
}
return (
<div className="tabbed-view tabbed-view--new">
<div className="tabbed-view-header">
<ul className="gf-tabs">
{this.tabs.map(tab => {
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
})}
</ul>
<div className="panel-editor-container__editor">
{
// <div className="panel-editor__close">
// <i className="fa fa-arrow-left" />
// </div>
// <div className="panel-editor-resizer">
// <div className="panel-editor-resizer__handle">
// <div className="panel-editor-resizer__handle-dots" />
// </div>
// </div>
}
<button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
<i className="fa fa-remove" />
</button>
</div>
<div className="tabbed-view-body">
{activeTab === 'queries' && this.renderQueriesTab()}
{activeTab === 'visualization' && this.renderVizTab()}
<div className="panel-editor-tabs">
{tabs.map(tab => {
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
})}
</div>
<div className="panel-editor__right">{this.renderCurrentTab(activeTab)}</div>
</div>
);
}
@ -116,15 +131,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 (
<li className="gf-tabs-item" key={tab.id}>
<a className={tabClasses} onClick={() => onClick(tab)}>
<i className={tab.icon} /> {tab.text}
<div className="panel-editor-tabs__item" onClick={() => onClick(tab)}>
<a className={tabClasses}>
<Tooltip content={`${tab.text}`} className="popper__manager--block" placement="auto">
<i className={`gicon gicon-${tab.id}${activeTab === tab.id ? '-active' : ''}`} />
</Tooltip>
</a>
</li>
</div>
);
}

View File

@ -1,51 +1,88 @@
import React, { PureComponent } from 'react';
import React, { Component } from 'react';
import classNames from 'classnames';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
import { DashboardModel } from 'app/features/dashboard/dashboard_model';
import { PanelModel } from 'app/features/dashboard/panel_model';
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
timeInfo: string;
title?: string;
description?: string;
scopedVars?: string;
links?: [];
}
export class PanelHeader extends PureComponent<Props> {
interface State {
panelMenuOpen: boolean;
}
export class PanelHeader extends Component<Props, State> {
state = {
panelMenuOpen: false,
};
onMenuToggle = event => {
event.stopPropagation();
this.setState(prevState => ({
panelMenuOpen: !prevState.panelMenuOpen,
}));
};
closeMenu = () => {
this.setState({
panelMenuOpen: false,
});
};
render() {
const isFullscreen = false;
const isLoading = false;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
const { panel, dashboard } = this.props;
const { panel, dashboard, timeInfo } = this.props;
return (
<div className={panelHeaderClass}>
<span className="panel-info-corner">
<i className="fa" />
<span className="panel-info-corner-inner" />
</span>
{isLoading && (
<span className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</span>
)}
<div className="panel-title-container">
<div className="panel-title">
<span className="icon-gf panel-alert-icon" />
<span className="panel-title-text" data-toggle="dropdown">
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
<>
<PanelHeaderCorner
panel={panel}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
/>
<div className={panelHeaderClass}>
{isLoading && (
<span className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</span>
)}
<div className="panel-title-container" onClick={this.onMenuToggle}>
<div className="panel-title">
<span className="icon-gf panel-alert-icon" />
<span className="panel-title-text">
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
</span>
<PanelHeaderMenu panel={panel} dashboard={dashboard} />
{this.state.panelMenuOpen && (
<ClickOutsideWrapper onClick={this.closeMenu}>
<PanelHeaderMenu panel={panel} dashboard={dashboard} />
</ClickOutsideWrapper>
)}
<span className="panel-time-info">
<i className="fa fa-clock-o" /> 4m
</span>
{timeInfo && (
<span className="panel-time-info">
<i className="fa fa-clock-o" /> {timeInfo}
</span>
)}
</div>
</div>
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,94 @@
import React, { Component } from 'react';
import { PanelModel } from 'app/features/dashboard/panel_model';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import templateSrv from 'app/features/templating/template_srv';
import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
import Remarkable from 'remarkable';
enum InfoModes {
Error = 'Error',
Info = 'Info',
Links = 'Links',
}
interface Props {
panel: PanelModel;
title?: string;
description?: string;
scopedVars?: string;
links?: [];
}
export class PanelHeaderCorner extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
getInfoMode = () => {
const { panel } = this.props;
if (!!panel.description) {
return InfoModes.Info;
}
if (panel.links && panel.links.length) {
return InfoModes.Links;
}
return undefined;
};
getInfoContent = (): JSX.Element => {
const { panel } = this.props;
const markdown = panel.description;
const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
const html = (
<div className="markdown-html">
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
{panel.links &&
panel.links.length > 0 && (
<ul className="text-left">
{panel.links.map((link, idx) => {
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
return (
<li key={idx}>
<a className="panel-menu-link" href={info.href} target={info.target}>
{info.title}
</a>
</li>
);
})}
</ul>
)}
</div>
);
return html;
};
render() {
const infoMode: InfoModes | undefined = this.getInfoMode();
if (!infoMode) {
return null;
}
return (
<>
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
<Tooltip
content={this.getInfoContent}
className="popper__manager--block"
refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
placement="bottom-start"
>
<i className="fa" />
<span className="panel-info-corner-inner" />
</Tooltip>
) : null}
</>
);
}
}
export default PanelHeaderCorner;

View File

@ -35,6 +35,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
render() {
const { dashboard, panel } = this.props;
const menu = getPanelMenu(dashboard, panel);
return <div className="panel-menu-container dropdown">{this.renderItems(menu)}</div>;
return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
}
}

View File

@ -0,0 +1,26 @@
// Libraries
import React, { SFC } from 'react';
interface Props {
title?: string;
onClose?: () => void;
children: JSX.Element | JSX.Element[];
}
export const PanelOptionSection: SFC<Props> = props => {
return (
<div className="panel-option-section">
{props.title && (
<div className="panel-option-section__header">
{props.title}
{props.onClose && (
<button className="btn btn-link" onClick={props.onClose}>
<i className="fa fa-remove" />
</button>
)}
</div>
)}
<div className="panel-option-section__body">{props.children}</div>
</div>
);
};

View File

@ -0,0 +1,64 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
import { PanelPlugin, PanelProps } from 'app/types';
interface Props {
pluginId: string;
}
class PanelPluginNotFound extends PureComponent<Props> {
constructor(props) {
super(props);
}
render() {
const style = {
display: 'flex',
alignItems: 'center',
textAlign: 'center' as 'center',
height: '100%',
};
return (
<div style={style}>
<div className="alert alert-error" style={{ margin: '0 auto' }}>
Panel plugin with id {this.props.pluginId} could not be found
</div>
</div>
);
}
}
export function getPanelPluginNotFound(id: string): PanelPlugin {
const NotFound = class NotFound extends PureComponent<PanelProps> {
render() {
return <PanelPluginNotFound pluginId={id} />;
}
};
return {
id: id,
name: id,
sort: 100,
module: '',
baseUrl: '',
info: {
author: {
name: '',
},
description: '',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '',
version: '',
},
exports: {
Panel: NotFound,
},
};
}

View File

@ -1,24 +1,79 @@
// Libraries
import React, { PureComponent } from 'react';
import React, { SFC, PureComponent } from 'react';
import Remarkable from 'remarkable';
import _ from 'lodash';
// Services & utils
// Components
import './../../panel/metrics_tab';
import { EditorTabBody } from './EditorTabBody';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { QueryInspector } from './QueryInspector';
import { QueryOptions } from './QueryOptions';
import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
import { PanelOptionSection } from './PanelOptionSection';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import config from 'app/core/config';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { DataSourceSelectItem, DataQuery } from 'app/types';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
export class QueriesTab extends PureComponent<Props> {
element: any;
interface State {
currentDS: DataSourceSelectItem;
helpContent: JSX.Element;
isLoadingHelp: boolean;
isPickerOpen: boolean;
isAddingMixed: boolean;
}
interface LoadingPlaceholderProps {
text: string;
}
const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
export class QueriesTab extends PureComponent<Props, State> {
element: HTMLElement;
component: AngularComponent;
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
backendSrv: BackendSrv = getBackendSrv();
constructor(props) {
super(props);
const { panel } = props;
this.state = {
currentDS: this.datasources.find(datasource => datasource.value === panel.datasource),
isLoadingHelp: false,
helpContent: null,
isPickerOpen: false,
isAddingMixed: false,
};
}
getAngularQueryComponentScope(): AngularQueryComponentScope {
const { panel, dashboard } = this.props;
return {
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
render: () => panel.render,
addQuery: this.onAddQuery,
moveQuery: this.onMoveQuery,
removeQuery: this.onRemoveQuery,
events: panel.events,
};
}
componentDidMount() {
@ -26,16 +81,10 @@ export class QueriesTab extends PureComponent<Props> {
return;
}
const { panel, dashboard } = this.props;
const loader = getAngularLoader();
const template = '<metrics-tab />';
const scopeProps = {
ctrl: {
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
},
ctrl: this.getAngularQueryComponentScope(),
};
this.component = loader.load(this.element, scopeProps, template);
@ -47,7 +96,190 @@ export class QueriesTab extends PureComponent<Props> {
}
}
onChangeDataSource = datasource => {
const { panel } = this.props;
const { currentDS } = 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 (currentDS) {
// if switching from mixed
if (currentDS.meta.mixed) {
for (const target of panel.targets) {
delete target.datasource;
}
} else if (currentDS.meta.id !== datasource.meta.id) {
// we are changing data source type, clear queries
panel.targets = [{ refId: 'A' }];
}
}
panel.datasource = datasource.value;
panel.refresh();
this.setState({
currentDS: datasource,
});
};
loadHelp = () => {
const { currentDS } = this.state;
const hasHelp = currentDS.meta.hasQueryHelp;
if (hasHelp) {
this.setState({
helpContent: <h3>Loading help...</h3>,
isLoadingHelp: true,
});
this.backendSrv
.get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
.then(res => {
const md = new Remarkable();
const helpHtml = md.render(res);
this.setState({
helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
isLoadingHelp: false,
});
})
.catch(() => {
this.setState({
helpContent: <h3>'Error occured when loading help'</h3>,
isLoadingHelp: false,
});
});
}
};
renderQueryInspector = () => {
const { panel } = this.props;
return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
};
renderHelp = () => {
const { helpContent, isLoadingHelp } = this.state;
return isLoadingHelp ? <LoadingPlaceholder text="Loading help..." /> : helpContent;
};
onAddQuery = (query?: Partial<DataQuery>) => {
this.props.panel.addQuery(query);
this.forceUpdate();
};
onAddQueryClick = () => {
if (this.state.currentDS.meta.mixed) {
this.setState({ isAddingMixed: true });
return;
}
this.props.panel.addQuery();
this.component.digest();
this.forceUpdate();
};
onRemoveQuery = (query: DataQuery) => {
const { panel } = this.props;
const index = _.indexOf(panel.targets, query);
panel.targets.splice(index, 1);
panel.refresh();
this.forceUpdate();
};
onMoveQuery = (query: DataQuery, direction: number) => {
const { panel } = this.props;
const index = _.indexOf(panel.targets, query);
_.move(panel.targets, index, index + direction);
this.forceUpdate();
};
renderToolbar = () => {
const { currentDS } = this.state;
return <DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />;
};
renderMixedPicker = () => {
return (
<DataSourcePicker
datasources={this.datasources}
onChange={this.onAddMixedQuery}
current={null}
autoFocus={true}
onBlur={this.onMixedPickerBlur}
/>
);
};
onAddMixedQuery = datasource => {
this.onAddQuery({ datasource: datasource.name });
this.component.digest();
this.setState({ isAddingMixed: false });
};
onMixedPickerBlur = () => {
this.setState({ isAddingMixed: false });
};
render() {
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
const { panel } = this.props;
const { currentDS, isAddingMixed } = this.state;
const { hasQueryHelp } = currentDS.meta;
const queryInspector = {
title: 'Query Inspector',
render: this.renderQueryInspector,
};
const dsHelp = {
heading: 'Help',
icon: 'fa fa-question',
disabled: !hasQueryHelp,
onClick: this.loadHelp,
render: this.renderHelp,
};
return (
<EditorTabBody heading="Queries" renderToolbar={this.renderToolbar} toolbarItems={[queryInspector, dsHelp]}>
<>
<PanelOptionSection>
<div className="query-editor-rows">
<div ref={element => (this.element = element)} />
<div className="gf-form-query">
<div className="gf-form gf-form-query-letter-cell">
<label className="gf-form-label">
<span className="gf-form-query-letter-cell-carret muted">
<i className="fa fa-caret-down" />
</span>{' '}
<span className="gf-form-query-letter-cell-letter">{panel.getNextQueryLetter()}</span>
</label>
</div>
<div className="gf-form">
{!isAddingMixed && (
<button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
Add Query
</button>
)}
{isAddingMixed && this.renderMixedPicker()}
</div>
</div>
</div>
</PanelOptionSection>
<PanelOptionSection>
<QueryOptions panel={panel} datasource={currentDS} />
</PanelOptionSection>
</>
</EditorTabBody>
);
}
}

View File

@ -0,0 +1,220 @@
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<Props, State> {
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 = (
<>
<i className="fa fa-minus-square-o" /> Collapse All
</>
);
const expand = (
<>
<i className="fa fa-plus-square-o" /> 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 <LoadingPlaceholder text="Loading query inspector..." />;
}
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}
>
<i className="fa fa-clipboard" /> Copy to Clipboard
</CopyToClipboard>
</div>
{!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />}
{isMocking && (
<div className="query-troubleshooter__body">
<div className="gf-form p-l-1 gf-form--v-stretch">
<textarea
className="gf-form-input"
style={{ width: '95%' }}
rows={10}
onInput={this.setMockedResponse}
placeholder="JSON"
/>
</div>
</div>
)}
</>
);
}
}

View File

@ -0,0 +1,167 @@
// Libraries
import React, { PureComponent } from 'react';
// Utils
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
// Components
import { Switch } from 'app/core/components/Switch/Switch';
import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import DataSourceOption from './DataSourceOption';
// Types
import { PanelModel } from '../panel_model';
import { ValidationEvents, DataSourceSelectItem } from 'app/types';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: value => {
if (!value) {
return true;
}
return isValidTimeSpan(value);
},
errorMessage: 'Not a valid timespan',
},
],
};
const emptyToNull = (value: string) => {
return value === '' ? null : value;
};
interface Props {
panel: PanelModel;
datasource: DataSourceSelectItem;
}
export class QueryOptions extends PureComponent<Props> {
onOverrideTime = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
panel.timeFrom = emptyToNullValue;
panel.refresh();
}
};
onTimeShift = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
panel.timeShift = emptyToNullValue;
panel.refresh();
}
};
onToggleTimeOverride = () => {
const { panel } = this.props;
panel.hideTimeOverride = !panel.hideTimeOverride;
panel.refresh();
};
renderOptions() {
const { datasource, panel } = this.props;
const { queryOptions } = datasource.meta;
if (!queryOptions) {
return null;
}
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{' '}
<code>1m</code> if your data is written every minute. Access auto interval via variable{' '}
<code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can
be used in math expressions.
</>
),
},
};
return Object.keys(queryOptions).map(key => {
const options = allOptions[key];
return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />;
});
}
render = () => {
const hideTimeOverride = this.props.panel.hideTimeOverride;
return (
<div className="gf-form-inline">
{this.renderOptions()}
<div className="gf-form">
<span className="gf-form-label">Relative time</span>
<Input
type="text"
className="width-6"
placeholder="1h"
onBlur={this.onOverrideTime}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
/>
</div>
<div className="gf-form">
<span className="gf-form-label">Time shift</span>
<Input
type="text"
className="width-6"
placeholder="1h"
onBlur={this.onTimeShift}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
/>
</div>
<div className="gf-form-inline">
<Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
</div>
</div>
);
};
}

View File

@ -0,0 +1,221 @@
// Libraries
import React, { PureComponent } from 'react';
// Utils & Services
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
// Components
import { EditorTabBody } from './EditorTabBody';
import { VizTypePicker } from './VizTypePicker';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { PanelOptionSection } from './PanelOptionSection';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { PanelPlugin } from 'app/types/plugins';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onTypeChanged: (newType: PanelPlugin) => void;
}
interface State {
isVizPickerOpen: boolean;
searchQuery: string;
}
export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement;
angularOptions: AngularComponent;
searchInput: HTMLElement;
constructor(props) {
super(props);
this.state = {
isVizPickerOpen: false,
searchQuery: '',
};
}
getPanelDefaultOptions = () => {
const { panel, plugin } = this.props;
if (plugin.exports.PanelDefaults) {
return panel.getOptions(plugin.exports.PanelDefaults.options);
}
return panel.getOptions(plugin.exports.PanelDefaults);
};
renderPanelOptions() {
const { plugin, angularPanel } = this.props;
const { PanelOptions } = plugin.exports;
if (angularPanel) {
return <div ref={element => (this.element = element)} />;
}
return (
<PanelOptionSection>
{PanelOptions ? (
<PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />
) : (
<p>Visualization has no options</p>
)}
</PanelOptionSection>
);
}
componentDidMount() {
if (this.shouldLoadAngularOptions()) {
this.loadAngularOptions();
}
}
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 = scope.$$childHead.ctrl;
let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template +=
`
<div class="panel-option-section" ng-cloak>` +
(i > 0 ? `<div class="panel-option-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
`<div class="panel-option-section__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() {
this.cleanUpAngularOptions();
}
cleanUpAngularOptions() {
if (this.angularOptions) {
this.angularOptions.destroy();
this.angularOptions = null;
}
}
onPanelOptionsChanged = (options: any) => {
this.props.panel.updateOptions(options);
this.forceUpdate();
};
onOpenVizPicker = () => {
this.setState({ isVizPickerOpen: true });
};
onCloseVizPicker = () => {
this.setState({ isVizPickerOpen: false });
};
onSearchQueryChange = evt => {
const value = evt.target.value;
this.setState({
searchQuery: value,
});
};
renderToolbar = (): JSX.Element => {
const { plugin } = this.props;
const { searchQuery } = this.state;
if (this.state.isVizPickerOpen) {
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
onChange={this.onSearchQueryChange}
value={searchQuery}
ref={elem => elem && elem.focus()}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<button className="btn btn-link toolbar__close" onClick={this.onCloseVizPicker}>
<i className="fa fa-chevron-up" />
</button>
</>
);
} else {
return (
<div className="toolbar__main" onClick={this.onOpenVizPicker}>
<img className="toolbar__main-image" src={plugin.info.logos.small} />
<div className="toolbar__main-name">{plugin.name}</div>
<i className="fa fa-caret-down" />
</div>
);
}
};
onTypeChanged = (plugin: PanelPlugin) => {
if (plugin.id === this.props.plugin.id) {
this.setState({ isVizPickerOpen: false });
} else {
this.props.onTypeChanged(plugin);
}
};
render() {
const { plugin } = this.props;
const { isVizPickerOpen, searchQuery } = this.state;
return (
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}>
<>
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
<VizTypePicker
current={plugin}
onTypeChanged={this.onTypeChanged}
searchQuery={searchQuery}
onClose={this.onCloseVizPicker}
/>
</FadeIn>
{this.renderPanelOptions()}
</>
</EditorTabBody>
);
}
}

View File

@ -1,29 +1,31 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
import _ from 'lodash';
interface Props {
currentType: string;
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
import VizTypePickerPlugin from './VizTypePickerPlugin';
export interface Props {
current: PanelPlugin;
onTypeChanged: (newType: PanelPlugin) => void;
searchQuery: string;
onClose: () => void;
}
interface State {
pluginList: PanelPlugin[];
}
export class VizTypePicker extends PureComponent<Props> {
searchInput: HTMLElement;
pluginList = this.getPanelPlugins('');
export class VizTypePicker extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
pluginList: this.getPanelPlugins(''),
};
}
getPanelPlugins(filter) {
get maxSelectedIndex() {
const filteredPluginList = this.getFilteredPluginList();
return filteredPluginList.length - 1;
}
getPanelPlugins(filter): PanelPlugin[] {
const panels = _.chain(config.panels)
.filter({ hideFromList: false })
.map(item => item)
@ -33,35 +35,39 @@ export class VizTypePicker extends PureComponent<Props, State> {
return _.sortBy(panels, 'sort');
}
renderVizPlugin = (plugin, index) => {
const cssClass = classNames({
'viz-picker__item': true,
'viz-picker__item--selected': plugin.id === this.props.currentType,
});
renderVizPlugin = (plugin: PanelPlugin, index: number) => {
const { onTypeChanged } = this.props;
const isCurrent = plugin.id === this.props.current.id;
return (
<div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
<img className="viz-picker__item-img" src={plugin.info.logos.small} />
<div className="viz-picker__item-name">{plugin.name}</div>
</div>
<VizTypePickerPlugin
key={plugin.id}
isCurrent={isCurrent}
plugin={plugin}
onClick={() => onTypeChanged(plugin)}
/>
);
};
getFilteredPluginList = (): PanelPlugin[] => {
const { searchQuery } = this.props;
const regex = new RegExp(searchQuery, 'i');
const pluginList = this.pluginList;
const filtered = pluginList.filter(item => {
return regex.test(item.name);
});
return filtered;
};
render() {
const filteredPluginList = this.getFilteredPluginList();
return (
<div className="viz-picker">
<div className="viz-picker__search">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input type="text" className="gf-form-input" placeholder="Search type" />
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
</div>
<div className="viz-picker__items">
<CustomScrollbar>
<div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
</CustomScrollbar>
<div className="viz-picker-list">
{filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More