mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into permissions-code-to-enterprise
This commit is contained in:
@@ -29,7 +29,11 @@ _.move = (array, fromIndex, toIndex) => {
|
||||
import { coreModule, registerAngularDirectives } from './core/core';
|
||||
import { setupAngularRoutes } from './routes/routes';
|
||||
|
||||
declare var System: any;
|
||||
// import symlinked extensions
|
||||
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
|
||||
extensionsIndex.keys().forEach(key => {
|
||||
extensionsIndex(key);
|
||||
});
|
||||
|
||||
export class GrafanaApp {
|
||||
registerFunctions: any;
|
||||
@@ -119,7 +123,7 @@ export class GrafanaApp {
|
||||
coreModule.config(setupAngularRoutes);
|
||||
registerAngularDirectives();
|
||||
|
||||
const preBootRequires = [System.import('app/features/all')];
|
||||
const preBootRequires = [import('app/features/all')];
|
||||
|
||||
Promise.all(preBootRequires)
|
||||
.then(() => {
|
||||
|
||||
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { SFC } from 'react';
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
}
|
||||
|
||||
const PageLoader: SFC<Props> = ({ pageName }) => {
|
||||
const loadingText = `Loading ${pageName}...`;
|
||||
return (
|
||||
<div className="page-loader-wrapper">
|
||||
<i className="page-loader-wrapper__spinner fa fa-spinner fa-spin" />
|
||||
<div className="page-loader-wrapper__text">{loadingText}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoader;
|
||||
@@ -54,11 +54,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
};
|
||||
|
||||
onUserSelected = (user: User) => {
|
||||
this.setState({ userId: user ? user.id : 0 });
|
||||
this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 });
|
||||
};
|
||||
|
||||
onTeamSelected = (team: Team) => {
|
||||
this.setState({ teamId: team ? team.id : 0 });
|
||||
this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
|
||||
};
|
||||
|
||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||
@@ -86,7 +86,6 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
const newItem = this.state;
|
||||
const pickerClassName = 'width-20';
|
||||
const isValid = this.isValid();
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={onCancel}>
|
||||
@@ -111,21 +110,13 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
|
||||
{newItem.type === AclTarget.User ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker
|
||||
onSelected={this.onUserSelected}
|
||||
value={newItem.userId.toString()}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<UserPicker onSelected={this.onUserSelected} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newItem.type === AclTarget.Team ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker
|
||||
onSelected={this.onTeamSelected}
|
||||
value={newItem.teamId.toString()}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<TeamPicker onSelected={this.onTeamSelected} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -133,9 +124,8 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={newItem.permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={() => {}}
|
||||
value={item.permission}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent<Props> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={item.permission}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,56 +1,25 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
class DescriptionOption extends Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSelect(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseEnter(event) {
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.props.isFocused) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { option, children, className } = this.props;
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`description-picker-option__button btn btn-link ${className} width-19`}
|
||||
>
|
||||
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">{option.description}</div>
|
||||
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
|
||||
<div className="muted width-17">{data.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionOption;
|
||||
export default Option;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
|
||||
export interface Props {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
onSelected: (permission) => void;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface OptionWithDescription {
|
||||
value: any;
|
||||
@@ -16,24 +11,38 @@ export interface OptionWithDescription {
|
||||
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, value, disabled, className } = this.props;
|
||||
|
||||
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
|
||||
const selectedOption = getSelectedOption(optionsWithDesc, value);
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
<Select
|
||||
value={value}
|
||||
valueKey="value"
|
||||
multi={false}
|
||||
clearable={false}
|
||||
labelKey="label"
|
||||
options={optionsWithDesc}
|
||||
onChange={onSelected}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={DescriptionOption}
|
||||
placeholder="Choose"
|
||||
disabled={disabled}
|
||||
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>
|
||||
);
|
||||
|
||||
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
|
||||
export const IndicatorsContainer = props => {
|
||||
const isOpen = props.selectProps.menuIsOpen;
|
||||
return (
|
||||
<components.IndicatorsContainer {...props}>
|
||||
<span
|
||||
className={`gf-form-select-box__select-arrow ${isOpen ? `gf-form-select-box__select-arrow--reversed` : ''}`}
|
||||
/>
|
||||
</components.IndicatorsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorsContainer;
|
||||
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
@@ -3,10 +3,26 @@ import renderer from 'react-test-renderer';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
const model = {
|
||||
onSelect: () => {},
|
||||
onFocus: () => {},
|
||||
isFocused: () => {},
|
||||
option: {
|
||||
cx: jest.fn(),
|
||||
clearValue: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
getStyles: jest.fn(),
|
||||
getValue: jest.fn(),
|
||||
hasValue: true,
|
||||
isMulti: false,
|
||||
options: [],
|
||||
selectOption: jest.fn(),
|
||||
selectProps: {},
|
||||
setValue: jest.fn(),
|
||||
isDisabled: false,
|
||||
isFocused: false,
|
||||
isSelected: false,
|
||||
innerRef: null,
|
||||
innerProps: null,
|
||||
label: 'Option label',
|
||||
type: null,
|
||||
children: 'Model title',
|
||||
data: {
|
||||
title: 'Model title',
|
||||
avatarUrl: 'url/to/avatar',
|
||||
label: 'User picker label',
|
||||
|
||||
@@ -1,54 +1,22 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
class UserPickerOption extends Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSelect(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseEnter(event) {
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.props.isFocused) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { option, children, className } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`user-picker-option__button btn btn-link ${className}`}
|
||||
>
|
||||
<img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPickerOption;
|
||||
export default PickerOption;
|
||||
|
||||
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
clearIndicator: () => ({}),
|
||||
container: () => ({}),
|
||||
control: () => ({}),
|
||||
dropdownIndicator: () => ({}),
|
||||
group: () => ({}),
|
||||
groupHeading: () => ({}),
|
||||
indicatorsContainer: () => ({}),
|
||||
indicatorSeparator: () => ({}),
|
||||
input: () => ({}),
|
||||
loadingIndicator: () => ({}),
|
||||
loadingMessage: () => ({}),
|
||||
menu: () => ({}),
|
||||
menuList: () => ({}),
|
||||
multiValue: () => ({}),
|
||||
multiValueLabel: () => ({}),
|
||||
multiValueRemove: () => ({}),
|
||||
noOptionsMessage: () => ({}),
|
||||
option: () => ({}),
|
||||
placeholder: () => ({}),
|
||||
singleValue: () => ({}),
|
||||
valueContainer: () => ({}),
|
||||
};
|
||||
@@ -1,18 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (team: Team) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading;
|
||||
}
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
@@ -21,6 +14,15 @@ export interface Team {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
onSelected: (team: Team) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export class TeamPicker extends Component<Props, State> {
|
||||
debouncedSearch: any;
|
||||
|
||||
@@ -31,7 +33,7 @@ export class TeamPicker extends Component<Props, State> {
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
trailing: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ export class TeamPicker extends Component<Props, State> {
|
||||
const backendSrv = getBackendSrv();
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => {
|
||||
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
@@ -50,31 +52,34 @@ export class TeamPicker extends Component<Props, State> {
|
||||
});
|
||||
|
||||
this.setState({ isLoading: false });
|
||||
return { options: teams };
|
||||
return teams;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSelected, value, className } = this.props;
|
||||
const { onSelected, className } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No teams found"
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
placeholder="Select a team"
|
||||
value={value}
|
||||
autosize={true}
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No teams found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { User } from 'app/types';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (user: User) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -31,20 +33,17 @@ export class UserPicker extends Component<Props, State> {
|
||||
|
||||
search(query?: string) {
|
||||
const backendSrv = getBackendSrv();
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return backendSrv
|
||||
.get(`/api/org/users?query=${query}&limit=10`)
|
||||
.then(result => {
|
||||
return {
|
||||
options: result.map(user => ({
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
})),
|
||||
};
|
||||
return result.map(user => ({
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ isLoading: false });
|
||||
@@ -52,26 +51,30 @@ export class UserPicker extends Component<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, className } = this.props;
|
||||
const { className, onSelected } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={this.props.onSelected}
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
placeholder="Select user"
|
||||
value={value}
|
||||
autosize={true}
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No users found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<button
|
||||
className="user-picker-option__button btn btn-link class-for-user-picker"
|
||||
onMouseDown={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
onMouseMove={[Function]}
|
||||
title="Model title"
|
||||
>
|
||||
<img
|
||||
alt="User picker label"
|
||||
className="user-picker-option__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</button>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -5,85 +5,115 @@ exports[`TeamPicker renders correctly 1`] = `
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
className="css-0 gf-form-select-box__control"
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
className="css-0 gf-form-select-box__value-container"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
className="css-0 gf-form-select-box__placeholder"
|
||||
>
|
||||
Loading...
|
||||
Select a team
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
className="css-0"
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-owns=""
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="gf-form-select-box__input"
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
disabled={false}
|
||||
id="react-select-2-input"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
spellCheck="false"
|
||||
style={
|
||||
Object {
|
||||
"background": 0,
|
||||
"border": 0,
|
||||
"boxSizing": "content-box",
|
||||
"color": "inherit",
|
||||
"fontSize": "inherit",
|
||||
"opacity": 1,
|
||||
"outline": 0,
|
||||
"padding": 0,
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
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=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
<div
|
||||
className="css-0 gf-form-select-box__indicators"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
className="gf-form-select-box__select-arrow "
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,85 +5,115 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
className="css-0 gf-form-select-box__control"
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
className="css-0 gf-form-select-box__value-container"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
className="css-0 gf-form-select-box__placeholder"
|
||||
>
|
||||
Loading...
|
||||
Select user
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
className="css-0"
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-owns=""
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="gf-form-select-box__input"
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
disabled={false}
|
||||
id="react-select-2-input"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
spellCheck="false"
|
||||
style={
|
||||
Object {
|
||||
"background": 0,
|
||||
"border": 0,
|
||||
"boxSizing": "content-box",
|
||||
"color": "inherit",
|
||||
"fontSize": "inherit",
|
||||
"opacity": 1,
|
||||
"outline": 0,
|
||||
"padding": 0,
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
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=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
<div
|
||||
className="css-0 gf-form-select-box__indicators"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
className="gf-form-select-box__select-arrow "
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,12 @@ export interface Props {
|
||||
label: string;
|
||||
removeIcon: boolean;
|
||||
count: number;
|
||||
onClick: any;
|
||||
onClick?: any;
|
||||
}
|
||||
|
||||
export class TagBadge extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
this.props.onClick(event);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -28,7 +23,7 @@ export class TagBadge extends React.Component<Props, any> {
|
||||
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
|
||||
|
||||
return (
|
||||
<span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
|
||||
<span className={`label label-tag`} style={tagStyle}>
|
||||
{removeIcon && <i className="fa fa-remove" />}
|
||||
{label} {countLabel}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { Async } from 'react-select';
|
||||
import { TagValue } from './TagValue';
|
||||
import AsyncSelect from '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';
|
||||
|
||||
export interface Props {
|
||||
tags: string[];
|
||||
@@ -18,15 +21,15 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
|
||||
this.searchTags = this.searchTags.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onTagRemove = this.onTagRemove.bind(this);
|
||||
}
|
||||
|
||||
searchTags(query) {
|
||||
return this.props.tagOptions().then(options => {
|
||||
const tags = _.map(options, tagOption => {
|
||||
return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
|
||||
});
|
||||
return { options: tags };
|
||||
return options.map(option => ({
|
||||
value: option.term,
|
||||
label: option.term,
|
||||
count: option.count,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,33 +37,44 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
this.props.onSelect(newTags);
|
||||
}
|
||||
|
||||
onTagRemove(tag) {
|
||||
let newTags = _.without(this.props.tags, tag.label);
|
||||
newTags = _.map(newTags, tag => {
|
||||
return { value: tag };
|
||||
});
|
||||
this.props.onSelect(newTags);
|
||||
}
|
||||
|
||||
render() {
|
||||
const selectOptions = {
|
||||
classNamePrefix: 'gf-form-select-box',
|
||||
isMulti: true,
|
||||
defaultOptions: true,
|
||||
loadOptions: this.searchTags,
|
||||
onChange: this.onChange,
|
||||
value: this.props.tags,
|
||||
multi: true,
|
||||
className: 'gf-form-input gf-form-input--form-dropdown',
|
||||
placeholder: 'Tags',
|
||||
loadingPlaceholder: 'Loading...',
|
||||
noResultsText: 'No tags found',
|
||||
optionComponent: TagOption,
|
||||
};
|
||||
loadingMessage: () => 'Loading...',
|
||||
noOptionsMessage: () => 'No tags found',
|
||||
getOptionValue: i => i.value,
|
||||
getOptionLabel: i => i.label,
|
||||
value: this.props.tags,
|
||||
styles: ResetStyles,
|
||||
components: {
|
||||
Option: TagOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
MultiValueLabel: () => {
|
||||
return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
|
||||
},
|
||||
MultiValueRemove: props => {
|
||||
const { data } = props;
|
||||
|
||||
selectOptions['valueComponent'] = TagValue;
|
||||
return (
|
||||
<components.MultiValueRemove {...props}>
|
||||
<TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
|
||||
</components.MultiValueRemove>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gf-form gf-form--has-input-icon gf-form--grow">
|
||||
<div className="tag-filter">
|
||||
<Async {...selectOptions} />
|
||||
<AsyncSelect {...selectOptions} />
|
||||
</div>
|
||||
<i className="gf-form-input-icon fa fa-tag" />
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,22 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class TagOption extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
export const TagOption = (props: ExtendedOptionProps) => {
|
||||
const { data, className, label } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`tag-filter-option btn btn-link ${className || ''}`}>
|
||||
<TagBadge label={label} removeIcon={false} count={data.count} />
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSelect(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseEnter(event) {
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.props.isFocused) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { option, className } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`tag-filter-option btn btn-link ${className || ''}`}
|
||||
>
|
||||
<TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default TagOption;
|
||||
|
||||
@@ -21,6 +21,6 @@ export class TagValue extends React.Component<Props, any> {
|
||||
render() {
|
||||
const { value } = this.props;
|
||||
|
||||
return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
|
||||
return <TagBadge label={value.label} removeIcon={false} count={0} onClick={this.onClick} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
|
||||
const template =
|
||||
'<move-to-folder-modal dismiss="dismiss()" ' +
|
||||
'dashboards="model.dashboards" after-save="model.afterSave()">' +
|
||||
'</move-to-folder-modal>`';
|
||||
'</move-to-folder-modal>';
|
||||
appEvents.emit('show-modal', {
|
||||
templateHtml: template,
|
||||
modalClass: 'modal--narrow',
|
||||
|
||||
@@ -160,8 +160,12 @@ export class SearchCtrl {
|
||||
searchDashboards() {
|
||||
this.currentSearchId = this.currentSearchId + 1;
|
||||
const localSearchId = this.currentSearchId;
|
||||
const query = {
|
||||
...this.query,
|
||||
tag: this.query.tag.map(i => i.value),
|
||||
};
|
||||
|
||||
return this.searchSrv.search(this.query).then(results => {
|
||||
return this.searchSrv.search(query).then(results => {
|
||||
if (localSearchId < this.currentSearchId) {
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +200,7 @@ export class SearchCtrl {
|
||||
}
|
||||
|
||||
onTagSelect(newTags) {
|
||||
this.query.tag = _.map(newTags, tag => tag.value);
|
||||
this.query.tag = newTags;
|
||||
this.search();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
|
||||
render() {
|
||||
return [
|
||||
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
||||
<img src="public/img/grafana_icon.svg" alt="graphana_logo" />
|
||||
<img src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||
</div>,
|
||||
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
||||
<i className="fa fa-bars" />
|
||||
|
||||
@@ -8,7 +8,7 @@ Array [
|
||||
onClick={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="graphana_logo"
|
||||
alt="Grafana"
|
||||
src="public/img/grafana_icon.svg"
|
||||
/>
|
||||
</div>,
|
||||
|
||||
@@ -11,7 +11,7 @@ export enum LogLevel {
|
||||
export interface LogSearchMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
text?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
@@ -21,7 +21,7 @@ export interface LogRow {
|
||||
timestamp: string;
|
||||
timeFromNow: string;
|
||||
timeLocal: string;
|
||||
searchMatches?: LogSearchMatch[];
|
||||
searchWords?: string[];
|
||||
}
|
||||
|
||||
export interface LogsModel {
|
||||
|
||||
@@ -399,6 +399,77 @@ describe('duration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock', () => {
|
||||
it('null', () => {
|
||||
const str = kbn.toClock(null, 0);
|
||||
expect(str).toBe('');
|
||||
});
|
||||
it('size less than 1 second', () => {
|
||||
const str = kbn.toClock(999, 0);
|
||||
expect(str).toBe('999ms');
|
||||
});
|
||||
describe('size less than 1 minute', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(59999);
|
||||
expect(str).toBe('59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(59999, 0);
|
||||
expect(str).toBe('59s');
|
||||
});
|
||||
});
|
||||
describe('size less than 1 hour', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(3599999);
|
||||
expect(str).toBe('59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(3599999, 0);
|
||||
expect(str).toBe('59m');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(3599999, 1);
|
||||
expect(str).toBe('59m:59s');
|
||||
});
|
||||
});
|
||||
describe('size greater than or equal 1 hour', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(7199999);
|
||||
expect(str).toBe('01h:59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(7199999, 0);
|
||||
expect(str).toBe('01h');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(7199999, 1);
|
||||
expect(str).toBe('01h:59m');
|
||||
});
|
||||
it('decimals equals 2', () => {
|
||||
const str = kbn.toClock(7199999, 2);
|
||||
expect(str).toBe('01h:59m:59s');
|
||||
});
|
||||
});
|
||||
describe('size greater than or equal 1 day', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(89999999);
|
||||
expect(str).toBe('24h:59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(89999999, 0);
|
||||
expect(str).toBe('24h');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(89999999, 1);
|
||||
expect(str).toBe('24h:59m');
|
||||
});
|
||||
it('decimals equals 2', () => {
|
||||
const str = kbn.toClock(89999999, 2);
|
||||
expect(str).toBe('24h:59m:59s');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('volume', () => {
|
||||
it('1000m3', () => {
|
||||
const str = kbn.valueFormats['m3'](1000, 1, null);
|
||||
|
||||
@@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
|
||||
const actual = nodeH.getOptimizedInputEdges();
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('when linking non-existing input node with existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('non-existing', 'A');
|
||||
}).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
|
||||
it('when linking existing input node with non-existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('A', 'non-existing');
|
||||
}).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,14 @@ export class Edge {
|
||||
}
|
||||
|
||||
link(inputNode: Node, outputNode: Node) {
|
||||
if (!inputNode) {
|
||||
throw Error('inputNode is required');
|
||||
}
|
||||
|
||||
if (!outputNode) {
|
||||
throw Error('outputNode is required');
|
||||
}
|
||||
|
||||
this.unlink();
|
||||
this.inputNode = inputNode;
|
||||
this.outputNode = outputNode;
|
||||
@@ -152,7 +160,11 @@ export class Graph {
|
||||
for (let n = 0; n < inputArr.length; n++) {
|
||||
const i = inputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
inputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
inputNodes.push(n);
|
||||
} else {
|
||||
inputNodes.push(i);
|
||||
}
|
||||
@@ -161,7 +173,11 @@ export class Graph {
|
||||
for (let n = 0; n < outputArr.length; n++) {
|
||||
const i = outputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
outputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
outputNodes.push(n);
|
||||
} else {
|
||||
outputNodes.push(i);
|
||||
}
|
||||
|
||||
@@ -36,14 +36,40 @@ describe('state functions', () => {
|
||||
range: DEFAULT_RANGE,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a valid Explore state from URL parameter', () => {
|
||||
const paramValue =
|
||||
'%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D';
|
||||
expect(parseUrlState(paramValue)).toMatchObject({
|
||||
datasource: 'Local',
|
||||
queries: [{ query: 'metric' }],
|
||||
range: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a valid Explore state from a compact URL parameter', () => {
|
||||
const paramValue = '%5B"now-1h","now","Local","metric"%5D';
|
||||
expect(parseUrlState(paramValue)).toMatchObject({
|
||||
datasource: 'Local',
|
||||
queries: [{ query: 'metric' }],
|
||||
range: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeStateToUrlParam', () => {
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
datasourceName: 'foo',
|
||||
range: {
|
||||
from: 'now - 5h',
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
queries: [
|
||||
@@ -57,10 +83,33 @@ describe('state functions', () => {
|
||||
};
|
||||
expect(serializeStateToUrlParam(state)).toBe(
|
||||
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
datasourceName: 'foo',
|
||||
range: {
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
queries: [
|
||||
{
|
||||
query: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
query: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(serializeStateToUrlParam(state, true)).toBe(
|
||||
'["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interplay', () => {
|
||||
it('can parse the serialized state into the original state', () => {
|
||||
const state = {
|
||||
|
||||
@@ -60,7 +60,20 @@ export async function getExploreUrl(
|
||||
export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
if (initial) {
|
||||
try {
|
||||
return JSON.parse(decodeURI(initial));
|
||||
const parsed = JSON.parse(decodeURI(initial));
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length <= 3) {
|
||||
throw new Error('Error parsing compact URL state for Explore.');
|
||||
}
|
||||
const range = {
|
||||
from: parsed[0],
|
||||
to: parsed[1],
|
||||
};
|
||||
const datasource = parsed[2];
|
||||
const queries = parsed.slice(3).map(query => ({ query }));
|
||||
return { datasource, queries, range };
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -68,11 +81,19 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
export function serializeStateToUrlParam(state: ExploreState): string {
|
||||
export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: state.datasourceName,
|
||||
queries: state.queries.map(q => ({ query: q.query })),
|
||||
range: state.range,
|
||||
};
|
||||
if (compact) {
|
||||
return JSON.stringify([
|
||||
urlState.range.from,
|
||||
urlState.range.to,
|
||||
urlState.datasource,
|
||||
...urlState.queries.map(q => q.query),
|
||||
]);
|
||||
}
|
||||
return JSON.stringify(urlState);
|
||||
}
|
||||
|
||||
@@ -808,6 +808,51 @@ kbn.toDuration = (size, decimals, timeScale) => {
|
||||
return strings.join(', ');
|
||||
};
|
||||
|
||||
kbn.toClock = (size, decimals) => {
|
||||
if (size === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// < 1 second
|
||||
if (size < 1000) {
|
||||
return moment.utc(size).format('SSS\\m\\s');
|
||||
}
|
||||
|
||||
// < 1 minute
|
||||
if (size < 60000) {
|
||||
let format = 'ss\\s:SSS\\m\\s';
|
||||
if (decimals === 0) {
|
||||
format = 'ss\\s';
|
||||
}
|
||||
return moment.utc(size).format(format);
|
||||
}
|
||||
|
||||
// < 1 hour
|
||||
if (size < 3600000) {
|
||||
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||
if (decimals === 0) {
|
||||
format = 'mm\\m';
|
||||
} else if (decimals === 1) {
|
||||
format = 'mm\\m:ss\\s';
|
||||
}
|
||||
return moment.utc(size).format(format);
|
||||
}
|
||||
|
||||
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||
|
||||
const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
|
||||
|
||||
if (decimals === 0) {
|
||||
format = '';
|
||||
} else if (decimals === 1) {
|
||||
format = 'mm\\m';
|
||||
} else if (decimals === 2) {
|
||||
format = 'mm\\m:ss\\s';
|
||||
}
|
||||
|
||||
return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
|
||||
};
|
||||
|
||||
kbn.valueFormats.dtdurationms = (size, decimals) => {
|
||||
return kbn.toDuration(size, decimals, 'millisecond');
|
||||
};
|
||||
@@ -824,6 +869,14 @@ kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
|
||||
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.clockms = (size, decimals) => {
|
||||
return kbn.toClock(size, decimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.clocks = (size, decimals) => {
|
||||
return kbn.toClock(size * 1000, decimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
|
||||
const time = isUtc ? moment.utc(epoch) : moment(epoch);
|
||||
|
||||
@@ -901,6 +954,8 @@ kbn.getUnitFormats = () => {
|
||||
{ text: 'duration (s)', value: 'dtdurations' },
|
||||
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
|
||||
{ text: 'Timeticks (s/100)', value: 'timeticks' },
|
||||
{ text: 'clock (ms)', value: 'clockms' },
|
||||
{ text: 'clock (s)', value: 'clocks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
24
public/app/core/utils/text.test.ts
Normal file
24
public/app/core/utils/text.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { findMatchesInText } from './text';
|
||||
|
||||
describe('findMatchesInText()', () => {
|
||||
it('gets no matches for when search and or line are empty', () => {
|
||||
expect(findMatchesInText('', '')).toEqual([]);
|
||||
expect(findMatchesInText('foo', '')).toEqual([]);
|
||||
expect(findMatchesInText('', 'foo')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets no matches for unmatched search string', () => {
|
||||
expect(findMatchesInText('foo', 'bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets matches for matched search string', () => {
|
||||
expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
|
||||
expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
|
||||
});
|
||||
|
||||
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
|
||||
{ length: 3, start: 1, text: 'foo', end: 4 },
|
||||
{ length: 3, start: 5, text: 'foo', end: 8 },
|
||||
{ length: 3, start: 9, text: 'bar', end: 12 },
|
||||
]);
|
||||
});
|
||||
32
public/app/core/utils/text.ts
Normal file
32
public/app/core/utils/text.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TextMatch } from 'app/types/explore';
|
||||
|
||||
/**
|
||||
* Adapt findMatchesInText for react-highlight-words findChunks handler.
|
||||
* See https://github.com/bvaughn/react-highlight-words#props
|
||||
*/
|
||||
export function findHighlightChunksInText({ searchWords, textToHighlight }) {
|
||||
return findMatchesInText(textToHighlight, searchWords.join(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of substring regexp matches.
|
||||
*/
|
||||
export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
|
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!haystack || !needle) {
|
||||
return [];
|
||||
}
|
||||
const regexp = new RegExp(`(?:${needle})`, 'g');
|
||||
const matches = [];
|
||||
let match = regexp.exec(haystack);
|
||||
while (match) {
|
||||
matches.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
match = regexp.exec(haystack);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { makeRegions, dedupAnnotations } from './events_processing';
|
||||
export class AnnotationsSrv {
|
||||
globalAnnotationsPromise: any;
|
||||
alertStatesPromise: any;
|
||||
datasourcePromises: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
|
||||
@@ -18,6 +19,7 @@ export class AnnotationsSrv {
|
||||
clearCache() {
|
||||
this.globalAnnotationsPromise = null;
|
||||
this.alertStatesPromise = null;
|
||||
this.datasourcePromises = null;
|
||||
}
|
||||
|
||||
getAnnotations(options) {
|
||||
@@ -90,6 +92,7 @@ export class AnnotationsSrv {
|
||||
|
||||
const range = this.timeSrv.timeRange();
|
||||
const promises = [];
|
||||
const dsPromises = [];
|
||||
|
||||
for (const annotation of dashboard.annotations.list) {
|
||||
if (!annotation.enable) {
|
||||
@@ -99,10 +102,10 @@ export class AnnotationsSrv {
|
||||
if (annotation.snapshotData) {
|
||||
return this.translateQueryResult(annotation, annotation.snapshotData);
|
||||
}
|
||||
|
||||
const datasourcePromise = this.datasourceSrv.get(annotation.datasource);
|
||||
dsPromises.push(datasourcePromise);
|
||||
promises.push(
|
||||
this.datasourceSrv
|
||||
.get(annotation.datasource)
|
||||
datasourcePromise
|
||||
.then(datasource => {
|
||||
// issue query against data source
|
||||
return datasource.annotationQuery({
|
||||
@@ -122,7 +125,7 @@ export class AnnotationsSrv {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.datasourcePromises = this.$q.all(dsPromises);
|
||||
this.globalAnnotationsPromise = this.$q.all(promises);
|
||||
return this.globalAnnotationsPromise;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
|
||||
navModel: {} as NavModel,
|
||||
apiKeys: [] as ApiKey[],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
loadApiKeys: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
setSearchQuery: jest.fn(),
|
||||
@@ -35,6 +36,7 @@ describe('Render', () => {
|
||||
it('should render API keys table', () => {
|
||||
const { wrapper } = setup({
|
||||
apiKeys: getMultipleMockKeys(5),
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
|
||||
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
||||
import config from 'app/core/config';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@@ -16,6 +17,7 @@ export interface Props {
|
||||
navModel: NavModel;
|
||||
apiKeys: ApiKey[];
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadApiKeys: typeof loadApiKeys;
|
||||
deleteApiKey: typeof deleteApiKey;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
@@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
});
|
||||
};
|
||||
|
||||
renderTable() {
|
||||
const { apiKeys } = this.props;
|
||||
|
||||
return [
|
||||
<h3 key="header" className="page-heading">
|
||||
Existing Keys
|
||||
</h3>,
|
||||
<table key="table" className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 && (
|
||||
<tbody>
|
||||
{apiKeys.map(key => {
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
)}
|
||||
</table>,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
const { newApiKey, isAdding } = this.state;
|
||||
const { navModel, apiKeys, searchQuery } = this.props;
|
||||
const { hasFetched, navModel, searchQuery } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
</form>
|
||||
</div>
|
||||
</SlideDown>
|
||||
|
||||
<h3 className="page-heading">Existing Keys</h3>
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 ? (
|
||||
<tbody>
|
||||
{apiKeys.map(key => {
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
) : null}
|
||||
</table>
|
||||
{hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -209,6 +220,7 @@ function mapStateToProps(state) {
|
||||
navModel: getNavModel(state.navIndex, 'apikeys'),
|
||||
apiKeys: getApiKeys(state.apiKeys),
|
||||
searchQuery: state.apiKeys.searchQuery,
|
||||
hasFetched: state.apiKeys.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
key="header"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
key="table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<PageLoader
|
||||
pageName="Api keys"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
|
||||
export const initialApiKeysState: ApiKeysState = {
|
||||
keys: [],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadApiKeys:
|
||||
return { ...state, keys: action.payload };
|
||||
return { ...state, hasFetched: true, keys: action.payload };
|
||||
case ActionTypes.SetApiKeysSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
|
||||
const mockKeys = getMultipleMockKeys(5);
|
||||
|
||||
it('should return all keys if no search query', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
|
||||
});
|
||||
|
||||
it('should filter keys if search query exists', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
|
||||
const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
|
||||
const count = this.props.panel.panels ? this.props.panel.panels.length : 0;
|
||||
const panels = count === 1 ? 'panel' : 'panels';
|
||||
const canEdit = this.dashboard.meta.canEdit === true;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
@@ -97,7 +98,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
|
||||
({count} {panels})
|
||||
</span>
|
||||
</a>
|
||||
{this.dashboard.meta.canEdit === true && (
|
||||
{canEdit && (
|
||||
<div className="dashboard-row__actions">
|
||||
<a className="pointer" onClick={this.openSettings}>
|
||||
<i className="fa fa-cog" />
|
||||
@@ -112,7 +113,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div className="dashboard-row__drag grid-drag-handle" />
|
||||
{canEdit && <div className="dashboard-row__drag grid-drag-handle" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ describe('DashboardRow', () => {
|
||||
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not show row drag handle when cannot edit', () => {
|
||||
dashboardMock.meta.canEdit = false;
|
||||
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
|
||||
expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should have zero actions when cannot edit', () => {
|
||||
dashboardMock.meta.canEdit = false;
|
||||
panel = new PanelModel({ collapsed: false });
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { AddDataSourcePermissions, Props } from './AddDataSourcePermissions';
|
||||
import { AclTarget } from '../../types/acl';
|
||||
|
||||
const setup = () => {
|
||||
const props: Props = {
|
||||
onAddPermission: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
};
|
||||
|
||||
return shallow(<AddDataSourcePermissions {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render user picker', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
wrapper.instance().setState({ type: AclTarget.User });
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { UserPicker } from 'app/core/components/Picker/UserPicker';
|
||||
import { Team, TeamPicker } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { dataSourceAclLevels, AclTarget, DataSourcePermissionLevel } from 'app/types/acl';
|
||||
import { User } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
onAddPermission: (state) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type: AclTarget;
|
||||
permission: DataSourcePermissionLevel;
|
||||
}
|
||||
|
||||
export class AddDataSourcePermissions extends PureComponent<Props, State> {
|
||||
cleanState = () => ({
|
||||
userId: 0,
|
||||
teamId: 0,
|
||||
type: AclTarget.Team,
|
||||
permission: DataSourcePermissionLevel.Query,
|
||||
});
|
||||
|
||||
state = this.cleanState();
|
||||
|
||||
isValid() {
|
||||
switch (this.state.type) {
|
||||
case AclTarget.Team:
|
||||
return this.state.teamId > 0;
|
||||
case AclTarget.User:
|
||||
return this.state.userId > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onTeamSelected = (team: Team) => {
|
||||
this.setState({ teamId: team ? team.id : 0 });
|
||||
};
|
||||
|
||||
onUserSelected = (user: User) => {
|
||||
this.setState({ userId: user ? user.id : 0 });
|
||||
};
|
||||
|
||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||
this.setState({ permission: permission.value });
|
||||
};
|
||||
|
||||
onTypeChanged = event => {
|
||||
const type = event.target.value as AclTarget;
|
||||
|
||||
this.setState({ type: type, userId: 0, teamId: 0 });
|
||||
};
|
||||
|
||||
onSubmit = async event => {
|
||||
event.preventDefault();
|
||||
|
||||
await this.props.onAddPermission(this.state);
|
||||
this.setState(this.cleanState());
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onCancel } = this.props;
|
||||
const { type, teamId, userId, permission } = this.state;
|
||||
|
||||
const pickerClassName = 'width-20';
|
||||
const aclTargets = [{ value: AclTarget.Team, text: 'Team' }, { value: AclTarget.User, text: 'User' }];
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={onCancel}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<form name="addPermission" onSubmit={this.onSubmit}>
|
||||
<h5>Add Permission For</h5>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<select className="gf-form-input gf-size-auto" value={type} onChange={this.onTypeChanged}>
|
||||
{aclTargets.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{type === AclTarget.User && (
|
||||
<div className="gf-form">
|
||||
<UserPicker onSelected={this.onUserSelected} value={userId.toString()} className={pickerClassName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === AclTarget.Team && (
|
||||
<div className="gf-form">
|
||||
<TeamPicker onSelected={this.onTeamSelected} value={teamId.toString()} className={pickerClassName} />
|
||||
</div>
|
||||
)}
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dataSourceAclLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<button data-save-permission className="btn btn-success" type="submit" disabled={!this.isValid()}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddDataSourcePermissions;
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataSourcePermissions, Props } from './DataSourcePermissions';
|
||||
import { DataSourcePermission, DataSourcePermissionDTO } from 'app/types';
|
||||
import { AclTarget, dashboardPermissionLevels } from '../../types/acl';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
dataSourcePermission: {} as DataSourcePermissionDTO,
|
||||
pageId: 1,
|
||||
addDataSourcePermission: jest.fn(),
|
||||
enableDataSourcePermissions: jest.fn(),
|
||||
disableDataSourcePermissions: jest.fn(),
|
||||
loadDataSourcePermissions: jest.fn(),
|
||||
removeDataSourcePermission: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<DataSourcePermissions {...props} />);
|
||||
const instance = wrapper.instance() as DataSourcePermissions;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render permissions enabled', () => {
|
||||
const { wrapper } = setup({
|
||||
dataSourcePermission: {
|
||||
enabled: true,
|
||||
datasourceId: 1,
|
||||
permissions: [] as DataSourcePermission[],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
describe('on add permissions', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
it('should add permissions for team', () => {
|
||||
const mockState = {
|
||||
permission: dashboardPermissionLevels[0].value,
|
||||
teamId: 1,
|
||||
type: AclTarget.Team,
|
||||
};
|
||||
|
||||
instance.onAddPermission(mockState);
|
||||
|
||||
expect(instance.props.addDataSourcePermission).toHaveBeenCalledWith(1, { teamId: 1, permission: 1 });
|
||||
});
|
||||
|
||||
it('should add permissions for user', () => {
|
||||
const mockState = {
|
||||
permission: dashboardPermissionLevels[0].value,
|
||||
userId: 1,
|
||||
type: AclTarget.User,
|
||||
};
|
||||
|
||||
instance.onAddPermission(mockState);
|
||||
|
||||
expect(instance.props.addDataSourcePermission).toHaveBeenCalledWith(1, { userId: 1, permission: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import SlideDown from '../../core/components/Animations/SlideDown';
|
||||
import AddDataSourcePermissions from './AddDataSourcePermissions';
|
||||
import DataSourcePermissionsList from './DataSourcePermissionsList';
|
||||
import { AclTarget } from 'app/types/acl';
|
||||
import {
|
||||
addDataSourcePermission,
|
||||
disableDataSourcePermissions,
|
||||
enableDataSourcePermissions,
|
||||
loadDataSourcePermissions,
|
||||
removeDataSourcePermission,
|
||||
} from './state/actions';
|
||||
import { DataSourcePermissionDTO } from 'app/types';
|
||||
import { getRouteParamsId } from '../../core/selectors/location';
|
||||
|
||||
export interface Props {
|
||||
dataSourcePermission: DataSourcePermissionDTO;
|
||||
pageId: number;
|
||||
addDataSourcePermission: typeof addDataSourcePermission;
|
||||
enableDataSourcePermissions: typeof enableDataSourcePermissions;
|
||||
disableDataSourcePermissions: typeof disableDataSourcePermissions;
|
||||
loadDataSourcePermissions: typeof loadDataSourcePermissions;
|
||||
removeDataSourcePermission: typeof removeDataSourcePermission;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isAdding: boolean;
|
||||
}
|
||||
|
||||
export class DataSourcePermissions extends PureComponent<Props, State> {
|
||||
state = {
|
||||
isAdding: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchDataSourcePermissions();
|
||||
}
|
||||
|
||||
async fetchDataSourcePermissions() {
|
||||
const { pageId, loadDataSourcePermissions } = this.props;
|
||||
|
||||
return await loadDataSourcePermissions(pageId);
|
||||
}
|
||||
|
||||
onOpenAddPermissions = () => {
|
||||
this.setState({
|
||||
isAdding: true,
|
||||
});
|
||||
};
|
||||
|
||||
onEnablePermissions = () => {
|
||||
const { pageId, enableDataSourcePermissions } = this.props;
|
||||
enableDataSourcePermissions(pageId);
|
||||
};
|
||||
|
||||
onDisablePermissions = () => {
|
||||
const { pageId, disableDataSourcePermissions } = this.props;
|
||||
|
||||
disableDataSourcePermissions(pageId);
|
||||
};
|
||||
|
||||
onAddPermission = state => {
|
||||
const { pageId, addDataSourcePermission } = this.props;
|
||||
const data = {
|
||||
permission: state.permission,
|
||||
};
|
||||
|
||||
if (state.type === AclTarget.Team) {
|
||||
addDataSourcePermission(pageId, Object.assign(data, { teamId: state.teamId }));
|
||||
} else if (state.type === AclTarget.User) {
|
||||
addDataSourcePermission(pageId, Object.assign(data, { userId: state.userId }));
|
||||
}
|
||||
};
|
||||
|
||||
onRemovePermission = item => {
|
||||
this.props.removeDataSourcePermission(item.datasourceId, item.id);
|
||||
};
|
||||
|
||||
onCancelAddPermission = () => {
|
||||
this.setState({
|
||||
isAdding: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { dataSourcePermission } = this.props;
|
||||
const { isAdding } = this.state;
|
||||
const isPermissionsEnabled = dataSourcePermission.enabled;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
<h3 className="page-sub-heading">Permissions</h3>
|
||||
<div className="page-action-bar__spacer" />
|
||||
{isPermissionsEnabled && [
|
||||
<button
|
||||
key="add-permission"
|
||||
className="btn btn-success pull-right"
|
||||
onClick={this.onOpenAddPermissions}
|
||||
disabled={isAdding}
|
||||
>
|
||||
<i className="fa fa-plus" /> Add Permission
|
||||
</button>,
|
||||
<button key="disable-permissions" className="btn btn-danger pull-right" onClick={this.onDisablePermissions}>
|
||||
Disable Permissions
|
||||
</button>,
|
||||
]}
|
||||
</div>
|
||||
{!isPermissionsEnabled ? (
|
||||
<div className="empty-list-cta">
|
||||
<div className="empty-list-cta__title">{'Permissions not enabled for this data source.'}</div>
|
||||
<button onClick={this.onEnablePermissions} className="empty-list-cta__button btn btn-xlarge btn-success">
|
||||
{'Enable'}
|
||||
</button>
|
||||
<div className="empty-list-cta__pro-tip">
|
||||
<i className="fa fa-rocket" /> ProTip:{' '}
|
||||
{'Only admins will be able to query the data source after you enable permissions.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<SlideDown in={isAdding}>
|
||||
<AddDataSourcePermissions
|
||||
onAddPermission={state => this.onAddPermission(state)}
|
||||
onCancel={this.onCancelAddPermission}
|
||||
/>
|
||||
</SlideDown>
|
||||
<DataSourcePermissionsList
|
||||
items={dataSourcePermission.permissions}
|
||||
onRemoveItem={item => this.onRemovePermission(item)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
pageId: getRouteParamsId(state.location),
|
||||
dataSourcePermission: state.dataSources.dataSourcePermission,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
addDataSourcePermission,
|
||||
enableDataSourcePermissions,
|
||||
disableDataSourcePermissions,
|
||||
loadDataSourcePermissions,
|
||||
removeDataSourcePermission,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DataSourcePermissions);
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataSourcePermissionsList, Props } from './DataSourcePermissionsList';
|
||||
import { DataSourcePermission } from '../../types';
|
||||
import { getMockDataSourcePermissionsTeam, getMockDataSourcePermissionsUser } from './__mocks__/dataSourcesMocks';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
items: [] as DataSourcePermission[],
|
||||
onRemoveItem: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<DataSourcePermissionsList {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render items', () => {
|
||||
const wrapper = setup({
|
||||
items: [getMockDataSourcePermissionsUser(), getMockDataSourcePermissionsTeam()],
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { DataSourcePermission } from 'app/types';
|
||||
import { dataSourceAclLevels, DataSourcePermissionLevel } from 'app/types/acl';
|
||||
import DescriptionPicker from '../../core/components/Picker/DescriptionPicker';
|
||||
|
||||
export interface Props {
|
||||
items: DataSourcePermission[];
|
||||
onRemoveItem: (item) => void;
|
||||
}
|
||||
|
||||
export class DataSourcePermissionsList extends PureComponent<Props> {
|
||||
renderAvatar(item) {
|
||||
if (item.teamId) {
|
||||
return <img className="filter-table__avatar" src={item.teamAvatarUrl} />;
|
||||
} else if (item.userId) {
|
||||
return <img className="filter-table__avatar" src={item.userAvatarUrl} />;
|
||||
}
|
||||
|
||||
return <i style={{ width: '25px', height: '25px' }} className="gicon gicon-viewer" />;
|
||||
}
|
||||
|
||||
renderDescription(item) {
|
||||
if (item.userId) {
|
||||
return [
|
||||
<span key="name">{item.userLogin} </span>,
|
||||
<span key="description" className="filter-table__weak-italic">
|
||||
(User)
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
if (item.teamId) {
|
||||
return [
|
||||
<span key="name">{item.team} </span>,
|
||||
<span key="description" className="filter-table__weak-italic">
|
||||
(Team)
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
return <span className="filter-table__weak-italic">(Role)</span>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items } = this.props;
|
||||
const permissionLevels = [...dataSourceAclLevels];
|
||||
permissionLevels.push({ value: DataSourcePermissionLevel.Admin, label: 'Admin', description: '' });
|
||||
|
||||
return (
|
||||
<table className="filter-table gf-form-group">
|
||||
<tbody>
|
||||
<tr className="gf-form-disabled">
|
||||
<td style={{ width: '1%' }}>
|
||||
<i style={{ width: '25px', height: '25px' }} className="gicon gicon-shield" />
|
||||
</td>
|
||||
<td style={{ width: '90%' }}>
|
||||
Admin
|
||||
<span className="filter-table__weak-italic"> (Role)</span>
|
||||
</td>
|
||||
<td />
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionLevels}
|
||||
onSelected={() => {}}
|
||||
value={2}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-inverse btn-small">
|
||||
<i className="fa fa-lock" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<tr key={`${item.id}-${index}`}>
|
||||
<td style={{ width: '1%' }}>{this.renderAvatar(item)}</td>
|
||||
<td style={{ width: '90%' }}>{this.renderDescription(item)}</td>
|
||||
<td />
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionLevels}
|
||||
onSelected={() => {}}
|
||||
value={1}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-small" onClick={() => this.props.onRemoveItem(item)}>
|
||||
<i className="fa fa-remove" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataSourcePermissionsList;
|
||||
@@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
|
||||
searchQuery: '',
|
||||
setDataSourcesSearchQuery: jest.fn(),
|
||||
setDataSourcesLayoutMode: jest.fn(),
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@@ -33,6 +34,7 @@ describe('Render', () => {
|
||||
const wrapper = setup({
|
||||
dataSources: getMockDataSources(5),
|
||||
dataSourcesCount: 5,
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
|
||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
@@ -22,6 +23,7 @@ export interface Props {
|
||||
dataSourcesCount: number;
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadDataSources: typeof loadDataSources;
|
||||
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
||||
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
||||
@@ -56,6 +58,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
searchQuery,
|
||||
setDataSourcesSearchQuery,
|
||||
setDataSourcesLayoutMode,
|
||||
hasFetched,
|
||||
} = this.props;
|
||||
|
||||
const linkButton = {
|
||||
@@ -67,10 +70,10 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
{dataSourcesCount === 0 ? (
|
||||
<EmptyListCTA model={emptyListModel} />
|
||||
) : (
|
||||
[
|
||||
{!hasFetched && <PageLoader pageName="Data sources" />}
|
||||
{hasFetched && dataSourcesCount === 0 && <EmptyListCTA model={emptyListModel} />}
|
||||
{hasFetched &&
|
||||
dataSourcesCount > 0 && [
|
||||
<OrgActionBar
|
||||
layoutMode={layoutMode}
|
||||
searchQuery={searchQuery}
|
||||
@@ -80,8 +83,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
key="action-bar"
|
||||
/>,
|
||||
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
||||
]
|
||||
)}
|
||||
]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -95,6 +97,7 @@ function mapStateToProps(state) {
|
||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
||||
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
||||
hasFetched: state.dataSources.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||
import DataSourcePermissions from './DataSourcePermissions';
|
||||
import { DataSource, NavModel } from 'app/types';
|
||||
import { loadDataSource } from './state/actions';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
|
||||
import { getDataSourceLoadingNav } from './state/navModel';
|
||||
import { getDataSource } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
dataSource: DataSource;
|
||||
dataSourceId: number;
|
||||
pageName: string;
|
||||
loadDataSource: typeof loadDataSource;
|
||||
}
|
||||
|
||||
enum PageTypes {
|
||||
Settings = 'settings',
|
||||
Permissions = 'permissions',
|
||||
Dashboards = 'dashboards',
|
||||
}
|
||||
|
||||
export class EditDataSourcePage extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.fetchDataSource();
|
||||
}
|
||||
|
||||
async fetchDataSource() {
|
||||
await this.props.loadDataSource(this.props.dataSourceId);
|
||||
}
|
||||
|
||||
isValidPage(currentPage) {
|
||||
return (Object as any).values(PageTypes).includes(currentPage);
|
||||
}
|
||||
|
||||
getCurrentPage() {
|
||||
const currentPage = this.props.pageName;
|
||||
|
||||
return this.isValidPage(currentPage) ? currentPage : PageTypes.Permissions;
|
||||
}
|
||||
|
||||
renderPage() {
|
||||
switch (this.getCurrentPage()) {
|
||||
case PageTypes.Permissions:
|
||||
return <DataSourcePermissions />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">{this.renderPage()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const pageName = getRouteParamsPage(state.location) || PageTypes.Permissions;
|
||||
const dataSourceId = getRouteParamsId(state.location);
|
||||
const dataSourceLoadingNav = getDataSourceLoadingNav(pageName);
|
||||
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, `datasource-${pageName}-${dataSourceId}`, dataSourceLoadingNav),
|
||||
dataSourceId: dataSourceId,
|
||||
dataSource: getDataSource(state.dataSources, dataSourceId),
|
||||
pageName: pageName,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadDataSource,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(EditDataSourcePage));
|
||||
@@ -1,179 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="gf-form-inline cta-form"
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<form
|
||||
name="addPermission"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<h5>
|
||||
Add Permission For
|
||||
</h5>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="Team"
|
||||
>
|
||||
<option
|
||||
key="0"
|
||||
value="Team"
|
||||
>
|
||||
Team
|
||||
</option>
|
||||
<option
|
||||
key="1"
|
||||
value="User"
|
||||
>
|
||||
User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<TeamPicker
|
||||
className="width-20"
|
||||
onSelected={[Function]}
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={false}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
data-save-permission={true}
|
||||
disabled={true}
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render user picker 1`] = `
|
||||
<div
|
||||
className="gf-form-inline cta-form"
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<form
|
||||
name="addPermission"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<h5>
|
||||
Add Permission For
|
||||
</h5>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="User"
|
||||
>
|
||||
<option
|
||||
key="0"
|
||||
value="Team"
|
||||
>
|
||||
Team
|
||||
</option>
|
||||
<option
|
||||
key="1"
|
||||
value="User"
|
||||
>
|
||||
User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<UserPicker
|
||||
className="width-20"
|
||||
onSelected={[Function]}
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={false}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
data-save-permission={true}
|
||||
disabled={true}
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,92 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<h3
|
||||
className="page-sub-heading"
|
||||
>
|
||||
Permissions
|
||||
</h3>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="empty-list-cta"
|
||||
>
|
||||
<div
|
||||
className="empty-list-cta__title"
|
||||
>
|
||||
Permissions not enabled for this data source.
|
||||
</div>
|
||||
<button
|
||||
className="empty-list-cta__button btn btn-xlarge btn-success"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
<div
|
||||
className="empty-list-cta__pro-tip"
|
||||
>
|
||||
<i
|
||||
className="fa fa-rocket"
|
||||
/>
|
||||
ProTip:
|
||||
|
||||
Only admins will be able to query the data source after you enable permissions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render permissions enabled 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<h3
|
||||
className="page-sub-heading"
|
||||
>
|
||||
Permissions
|
||||
</h3>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
disabled={false}
|
||||
key="add-permission"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
Add Permission
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger pull-right"
|
||||
key="disable-permissions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Disable Permissions
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Component
|
||||
in={false}
|
||||
>
|
||||
<AddDataSourcePermissions
|
||||
onAddPermission={[Function]}
|
||||
onCancel={[Function]}
|
||||
/>
|
||||
</Component>
|
||||
<DataSourcePermissionsList
|
||||
items={Array []}
|
||||
onRemoveItem={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,342 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<table
|
||||
className="filter-table gf-form-group"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
className="gf-form-disabled"
|
||||
>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "1%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<i
|
||||
className="gicon gicon-shield"
|
||||
style={
|
||||
Object {
|
||||
"height": "25px",
|
||||
"width": "25px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "90%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Admin
|
||||
<span
|
||||
className="filter-table__weak-italic"
|
||||
>
|
||||
(Role)
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="query-keyword"
|
||||
>
|
||||
Can
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={true}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={2}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-inverse btn-small"
|
||||
>
|
||||
<i
|
||||
className="fa fa-lock"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`Render should render items 1`] = `
|
||||
<table
|
||||
className="filter-table gf-form-group"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
className="gf-form-disabled"
|
||||
>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "1%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<i
|
||||
className="gicon gicon-shield"
|
||||
style={
|
||||
Object {
|
||||
"height": "25px",
|
||||
"width": "25px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "90%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Admin
|
||||
<span
|
||||
className="filter-table__weak-italic"
|
||||
>
|
||||
(Role)
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="query-keyword"
|
||||
>
|
||||
Can
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={true}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={2}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-inverse btn-small"
|
||||
>
|
||||
<i
|
||||
className="fa fa-lock"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="2-0"
|
||||
>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "1%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="/avatar/926aa85c6bcefa0b4deca3223f337ae1"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "90%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<span
|
||||
key="name"
|
||||
>
|
||||
testUser
|
||||
|
||||
</span>
|
||||
<span
|
||||
className="filter-table__weak-italic"
|
||||
key="description"
|
||||
>
|
||||
(User)
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="query-keyword"
|
||||
>
|
||||
Can
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={true}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={1}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-small"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="6-1"
|
||||
>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "1%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="/avatar/93c0801b955cbd443a8cfa91a401d7bc"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style={
|
||||
Object {
|
||||
"width": "90%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<span
|
||||
key="name"
|
||||
>
|
||||
A-team
|
||||
|
||||
</span>
|
||||
<span
|
||||
className="filter-table__weak-italic"
|
||||
key="description"
|
||||
>
|
||||
(Team)
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="query-keyword"
|
||||
>
|
||||
Can
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<DescriptionPicker
|
||||
className="gf-form-input--form-dropdown-right"
|
||||
disabled={true}
|
||||
onSelected={[Function]}
|
||||
optionsWithDesc={
|
||||
Array [
|
||||
Object {
|
||||
"description": "Can query data source.",
|
||||
"label": "Query",
|
||||
"value": 1,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
Object {
|
||||
"description": "",
|
||||
"label": "Admin",
|
||||
"value": 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
value={1}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-small"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
@@ -155,19 +155,8 @@ exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "gicon gicon-add-datasources",
|
||||
"buttonLink": "datasources/new",
|
||||
"buttonTitle": "Add data source",
|
||||
"proTip": "You can also define data sources through configuration files.",
|
||||
"proTipLink": "http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list",
|
||||
"proTipLinkTitle": "Learn more",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "There are no data sources defined yet",
|
||||
}
|
||||
}
|
||||
<PageLoader
|
||||
pageName="Data sources"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,13 @@ const initialState: DataSourcesState = {
|
||||
dataSourceTypeSearchQuery: '',
|
||||
dataSourceMeta: {} as Plugin,
|
||||
dataSourcePermission: {} as DataSourcePermissionDTO,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadDataSources:
|
||||
return { ...state, dataSources: action.payload, dataSourcesCount: action.payload.length };
|
||||
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
|
||||
|
||||
case ActionTypes.LoadDataSource:
|
||||
return { ...state, dataSource: action.payload };
|
||||
|
||||
@@ -9,6 +9,10 @@ import store from 'app/core/store';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import { parse as parseDate } from 'app/core/utils/datemath';
|
||||
import { DEFAULT_RANGE } from 'app/core/utils/explore';
|
||||
import ResetStyles from 'app/core/components/Picker/ResetStyles';
|
||||
import PickerOption from 'app/core/components/Picker/PickerOption';
|
||||
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
|
||||
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
|
||||
|
||||
import ElapsedTime from './ElapsedTime';
|
||||
import QueryRows from './QueryRows';
|
||||
@@ -275,7 +279,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
const { onChangeSplit } = this.props;
|
||||
if (onChangeSplit) {
|
||||
onChangeSplit(false);
|
||||
this.saveState();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,7 +295,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
if (onChangeSplit) {
|
||||
const state = this.cloneState();
|
||||
onChangeSplit(true, state);
|
||||
this.saveState();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -521,7 +523,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
const logsButtonActive = showingLogs ? 'active' : '';
|
||||
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
const selectedDatasource = datasource ? datasource.name : undefined;
|
||||
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
|
||||
|
||||
return (
|
||||
<div className={exploreClass} ref={this.getRef}>
|
||||
@@ -543,13 +545,23 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
{!datasourceMissing ? (
|
||||
<div className="navbar-buttons">
|
||||
<Select
|
||||
clearable={false}
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={datasourceLoading}
|
||||
isClearable={false}
|
||||
className="gf-form-input gf-form-input--form-dropdown datasource-picker"
|
||||
onChange={this.onChangeDatasource}
|
||||
options={exploreDatasources}
|
||||
isOpen={true}
|
||||
placeholder="Loading datasources..."
|
||||
styles={ResetStyles}
|
||||
placeholder="Select datasource"
|
||||
loadingMessage={() => 'Loading datasources...'}
|
||||
noOptionsMessage={() => 'No datasources found'}
|
||||
value={selectedDatasource}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { Fragment, PureComponent } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { LogsModel, LogRow } from 'app/core/logs_model';
|
||||
import { LogsModel } from 'app/core/logs_model';
|
||||
import { findHighlightChunksInText } from 'app/core/utils/text';
|
||||
|
||||
interface LogsProps {
|
||||
className?: string;
|
||||
@@ -10,34 +12,7 @@ interface LogsProps {
|
||||
|
||||
const EXAMPLE_QUERY = '{job="default/prometheus"}';
|
||||
|
||||
const Entry: React.SFC<LogRow> = props => {
|
||||
const { entry, searchMatches } = props;
|
||||
if (searchMatches && searchMatches.length > 0) {
|
||||
let lastMatchEnd = 0;
|
||||
const spans = searchMatches.reduce((acc, match, i) => {
|
||||
// Insert non-match
|
||||
if (match.start !== lastMatchEnd) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
|
||||
}
|
||||
// Match
|
||||
acc.push(
|
||||
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
|
||||
{entry.substr(match.start, match.length)}
|
||||
</span>
|
||||
);
|
||||
lastMatchEnd = match.start + match.length;
|
||||
// Non-matching end
|
||||
if (i === searchMatches.length - 1) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd)}</>);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return <>{spans}</>;
|
||||
}
|
||||
return <>{props.entry}</>;
|
||||
};
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, any> {
|
||||
export default class Logs extends PureComponent<LogsProps, {}> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
@@ -50,7 +25,12 @@ export default class Logs extends PureComponent<LogsProps, any> {
|
||||
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
|
||||
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
|
||||
<div>
|
||||
<Entry {...row} />
|
||||
<Highlighter
|
||||
textToHighlight={row.entry}
|
||||
searchWords={row.searchWords}
|
||||
findChunks={findHighlightChunksInText}
|
||||
highlightClassName="logs-row-match-highlight"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -145,7 +145,7 @@ interface PromQueryFieldProps {
|
||||
onClickHintFix?: (action: any) => void;
|
||||
onPressEnter?: () => void;
|
||||
onQueryChange?: (value: string, override?: boolean) => void;
|
||||
portalPrefix?: string;
|
||||
portalOrigin?: string;
|
||||
request?: (url: string) => any;
|
||||
supportsLogs?: boolean; // To be removed after Logging gets its own query field
|
||||
}
|
||||
@@ -158,6 +158,7 @@ interface PromQueryFieldState {
|
||||
metrics: string[];
|
||||
metricsOptions: any[];
|
||||
metricsByPrefix: CascaderOption[];
|
||||
syntaxLoaded: boolean;
|
||||
}
|
||||
|
||||
interface PromTypeaheadInput {
|
||||
@@ -191,6 +192,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
metrics: props.metrics || [],
|
||||
metricsByPrefix: props.metricsByPrefix || [],
|
||||
metricsOptions: [],
|
||||
syntaxLoaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,7 +268,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
}
|
||||
|
||||
// Update global prism config
|
||||
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
|
||||
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
|
||||
|
||||
// Build metrics tree
|
||||
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
||||
@@ -275,7 +277,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
...metricsByPrefix,
|
||||
];
|
||||
|
||||
this.setState({ metricsOptions });
|
||||
this.setState({ metricsOptions, syntaxLoaded: true });
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
@@ -308,10 +310,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
|
||||
return this.getAggregationTypeahead.apply(this, arguments);
|
||||
} else if (
|
||||
// Non-empty but not inside known token
|
||||
(prefix && !tokenRecognized) ||
|
||||
(prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
|
||||
text.match(/[+\-*/^%]/) // After binary operator
|
||||
// Show default suggestions in a couple of scenarios
|
||||
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
|
||||
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
|
||||
text.match(/[+\-*/^%]/) // Anything after binary operator
|
||||
) {
|
||||
return this.getEmptyTypeahead();
|
||||
}
|
||||
@@ -558,8 +560,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, hint, supportsLogs } = this.props;
|
||||
const { logLabelOptions, metricsOptions } = this.state;
|
||||
const { error, hint, initialQuery, supportsLogs } = this.props;
|
||||
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
|
||||
|
||||
return (
|
||||
<div className="prom-query-field">
|
||||
@@ -579,12 +581,13 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
<TypeaheadField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialValue={this.props.initialQuery}
|
||||
initialValue={initialQuery}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
portalPrefix="prometheus"
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
|
||||
@@ -104,8 +104,9 @@ interface TypeaheadFieldProps {
|
||||
onValueChanged?: (value: Value) => void;
|
||||
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
|
||||
placeholder?: string;
|
||||
portalPrefix?: string;
|
||||
portalOrigin?: string;
|
||||
syntax?: string;
|
||||
syntaxLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface TypeaheadFieldState {
|
||||
@@ -171,10 +172,15 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// initialValue is null in case the user typed
|
||||
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
|
||||
this.setState({ value: makeValue(nextProps.initialValue, nextProps.syntax) });
|
||||
componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
|
||||
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
|
||||
// Need a bogus edit to re-render the editor after syntax has fully loaded
|
||||
this.onChange(
|
||||
this.state.value
|
||||
.change()
|
||||
.insertText(' ')
|
||||
.deleteBackward()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,8 +459,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
};
|
||||
|
||||
renderMenu = () => {
|
||||
const { portalPrefix } = this.props;
|
||||
const { suggestions, typeaheadIndex } = this.state;
|
||||
const { portalOrigin } = this.props;
|
||||
const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
|
||||
if (!hasSuggestions(suggestions)) {
|
||||
return null;
|
||||
}
|
||||
@@ -463,11 +469,12 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
<Portal prefix={portalPrefix}>
|
||||
<Portal origin={portalOrigin}>
|
||||
<Typeahead
|
||||
menuRef={this.menuRef}
|
||||
selectedItem={selectedItem}
|
||||
onClickItem={this.onClickMenu}
|
||||
prefix={typeaheadPrefix}
|
||||
groupedItems={suggestions}
|
||||
/>
|
||||
</Portal>
|
||||
@@ -494,14 +501,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
}
|
||||
}
|
||||
|
||||
class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
|
||||
class Portal extends React.PureComponent<{ index?: number; origin: string }, {}> {
|
||||
node: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { index = 0, prefix = 'query' } = props;
|
||||
const { index = 0, origin = 'query' } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ class QueryRow extends PureComponent<any, {}> {
|
||||
hint={queryHint}
|
||||
initialQuery={query}
|
||||
history={history}
|
||||
portalPrefix="explore"
|
||||
onClickHintFix={this.onClickHintFix}
|
||||
onPressEnter={this.onPressEnter}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { Suggestion, SuggestionGroup } from './QueryField';
|
||||
|
||||
@@ -16,6 +17,7 @@ interface TypeaheadItemProps {
|
||||
isSelected: boolean;
|
||||
item: Suggestion;
|
||||
onClickItem: (Suggestion) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
@@ -38,11 +40,12 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSelected, item } = this.props;
|
||||
const { isSelected, item, prefix } = this.props;
|
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||
const { label } = item;
|
||||
return (
|
||||
<li ref={this.getRef} className={className} onClick={this.onClick}>
|
||||
{item.detail || item.label}
|
||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" />
|
||||
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
|
||||
</li>
|
||||
);
|
||||
@@ -54,18 +57,25 @@ interface TypeaheadGroupProps {
|
||||
label: string;
|
||||
onClickItem: (Suggestion) => void;
|
||||
selected: Suggestion;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
|
||||
render() {
|
||||
const { items, label, selected, onClickItem } = this.props;
|
||||
const { items, label, selected, onClickItem, prefix } = this.props;
|
||||
return (
|
||||
<li className="typeahead-group">
|
||||
<div className="typeahead-group__title">{label}</div>
|
||||
<ul className="typeahead-group__list">
|
||||
{items.map(item => {
|
||||
return (
|
||||
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
|
||||
<TypeaheadItem
|
||||
key={item.label}
|
||||
onClickItem={onClickItem}
|
||||
isSelected={selected === item}
|
||||
item={item}
|
||||
prefix={prefix}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
@@ -79,14 +89,15 @@ interface TypeaheadProps {
|
||||
menuRef: any;
|
||||
selectedItem: Suggestion | null;
|
||||
onClickItem: (Suggestion) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
|
||||
render() {
|
||||
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
|
||||
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
|
||||
return (
|
||||
<ul className="typeahead" ref={menuRef}>
|
||||
{groupedItems.map(g => (
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -38,10 +38,17 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
|
||||
|
||||
onChangeSplit = (split: boolean, splitState: ExploreState) => {
|
||||
this.setState({ split, splitState });
|
||||
// When closing split, remove URL state for split part
|
||||
if (!split) {
|
||||
delete this.urlStates[STATE_KEY_RIGHT];
|
||||
this.props.updateLocation({
|
||||
query: this.urlStates,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onSaveState = (key: string, state: ExploreState) => {
|
||||
const urlState = serializeStateToUrlParam(state);
|
||||
const urlState = serializeStateToUrlParam(state, true);
|
||||
this.urlStates[key] = urlState;
|
||||
this.props.updateLocation({
|
||||
query: this.urlStates,
|
||||
|
||||
@@ -13,22 +13,25 @@ const setup = (propOverrides?: object) => {
|
||||
setPluginsLayoutMode: jest.fn(),
|
||||
layoutMode: LayoutModes.Grid,
|
||||
loadPlugins: jest.fn(),
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<PluginListPage {...props} />);
|
||||
const instance = wrapper.instance() as PluginListPage;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
return shallow(<PluginListPage {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list', () => {
|
||||
const wrapper = setup({
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import PluginList from './PluginList';
|
||||
import { NavModel, Plugin } from 'app/types';
|
||||
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
|
||||
@@ -15,6 +16,7 @@ export interface Props {
|
||||
plugins: Plugin[];
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadPlugins: typeof loadPlugins;
|
||||
setPluginsLayoutMode: typeof setPluginsLayoutMode;
|
||||
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
||||
@@ -30,12 +32,21 @@ export class PluginListPage extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
|
||||
const {
|
||||
hasFetched,
|
||||
navModel,
|
||||
plugins,
|
||||
layoutMode,
|
||||
setPluginsLayoutMode,
|
||||
setPluginsSearchQuery,
|
||||
searchQuery,
|
||||
} = this.props;
|
||||
|
||||
const linkButton = {
|
||||
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
|
||||
title: 'Find more plugins on Grafana.com',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
@@ -47,7 +58,11 @@ export class PluginListPage extends PureComponent<Props> {
|
||||
setSearchQuery={query => setPluginsSearchQuery(query)}
|
||||
linkButton={linkButton}
|
||||
/>
|
||||
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
|
||||
{hasFetched ? (
|
||||
plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />
|
||||
) : (
|
||||
<PageLoader pageName="Plugins" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -60,6 +75,7 @@ function mapStateToProps(state) {
|
||||
plugins: getPlugins(state.plugins),
|
||||
layoutMode: getLayoutMode(state.plugins),
|
||||
searchQuery: getPluginsSearchQuery(state.plugins),
|
||||
hasFetched: state.plugins.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<OrgActionBar
|
||||
layoutMode="grid"
|
||||
linkButton={
|
||||
Object {
|
||||
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
|
||||
"title": "Find more plugins on Grafana.com",
|
||||
}
|
||||
}
|
||||
onSetLayoutMode={[Function]}
|
||||
searchQuery=""
|
||||
setSearchQuery={[Function]}
|
||||
/>
|
||||
<PageLoader
|
||||
pageName="Plugins"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render list 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
|
||||
@@ -6,12 +6,13 @@ export const initialState: PluginsState = {
|
||||
plugins: [] as Plugin[],
|
||||
searchQuery: '',
|
||||
layoutMode: LayoutModes.Grid,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadPlugins:
|
||||
return { ...state, plugins: action.payload };
|
||||
return { ...state, hasFetched: true, plugins: action.payload };
|
||||
|
||||
case ActionTypes.SetPluginsSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
@@ -13,6 +13,7 @@ const setup = (propOverrides?: object) => {
|
||||
setSearchQuery: jest.fn(),
|
||||
searchQuery: '',
|
||||
teamsCount: 0,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@@ -36,6 +37,7 @@ describe('Render', () => {
|
||||
const { wrapper } = setup({
|
||||
teams: getMultipleMockTeams(5),
|
||||
teamsCount: 5,
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { hot } from 'react-hot-loader';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { NavModel, Team } from '../../types';
|
||||
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
|
||||
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
|
||||
@@ -14,6 +15,7 @@ export interface Props {
|
||||
teams: Team[];
|
||||
searchQuery: string;
|
||||
teamsCount: number;
|
||||
hasFetched: boolean;
|
||||
loadTeams: typeof loadTeams;
|
||||
deleteTeam: typeof deleteTeam;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
@@ -103,7 +105,7 @@ export class TeamList extends PureComponent<Props, any> {
|
||||
<div className="page-action-bar__spacer" />
|
||||
|
||||
<a className="btn btn-success" href="org/teams/new">
|
||||
<i className="fa fa-plus" /> New team
|
||||
New team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -125,13 +127,23 @@ export class TeamList extends PureComponent<Props, any> {
|
||||
);
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const { teamsCount } = this.props;
|
||||
|
||||
if (teamsCount > 0) {
|
||||
return this.renderTeamList();
|
||||
} else {
|
||||
return this.renderEmptyList();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, teamsCount } = this.props;
|
||||
const { hasFetched, navModel } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
{teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
|
||||
{hasFetched ? this.renderList() : <PageLoader pageName="Teams" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +155,7 @@ function mapStateToProps(state) {
|
||||
teams: getTeams(state.teams),
|
||||
searchQuery: getSearchQuery(state.teams),
|
||||
teamsCount: getTeamsCount(state.teams),
|
||||
hasFetched: state.teams.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,8 @@ export class TeamMembers extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { newTeamMember, isAdding } = this.state;
|
||||
const { isAdding } = this.state;
|
||||
const { searchMemberQuery, members, syncEnabled } = this.props;
|
||||
const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
@@ -117,8 +115,7 @@ export class TeamMembers extends PureComponent<Props, State> {
|
||||
</button>
|
||||
<h5>Add Team Member</h5>
|
||||
<div className="gf-form-inline">
|
||||
<UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
|
||||
|
||||
<UserPicker onSelected={this.onUserSelected} className="width-30" />
|
||||
{this.state.newTeamMember && (
|
||||
<button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
|
||||
Add to team
|
||||
|
||||
@@ -5,24 +5,9 @@ exports[`Render should render component 1`] = `
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "fa fa-plus",
|
||||
"buttonLink": "org/teams/new",
|
||||
"buttonTitle": " New team",
|
||||
"proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
|
||||
"proTipLink": "",
|
||||
"proTipLinkTitle": "",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "You haven't created any teams yet.",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PageLoader
|
||||
pageName="Teams"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -62,10 +47,7 @@ exports[`Render should render teams table 1`] = `
|
||||
className="btn btn-success"
|
||||
href="org/teams/new"
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
New team
|
||||
New team
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -60,7 +60,6 @@ exports[`Render should render component 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +154,6 @@ exports[`Render should render team members 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +374,6 @@ exports[`Render should render team members when sync enabled 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
|
||||
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
|
||||
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false };
|
||||
export const initialTeamState: TeamState = {
|
||||
team: {} as Team,
|
||||
members: [] as TeamMember[],
|
||||
@@ -12,7 +12,7 @@ export const initialTeamState: TeamState = {
|
||||
export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadTeams:
|
||||
return { ...state, teams: action.payload };
|
||||
return { ...state, hasFetched: true, teams: action.payload };
|
||||
|
||||
case ActionTypes.SetSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('Teams selectors', () => {
|
||||
const mockTeams = getMultipleMockTeams(5);
|
||||
|
||||
it('should return teams if no search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Teams selectors', () => {
|
||||
});
|
||||
|
||||
it('Should filter teams if search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
|
||||
|
||||
@@ -291,9 +291,11 @@ export class VariableSrv {
|
||||
createGraph() {
|
||||
const g = new Graph();
|
||||
|
||||
this.variables.forEach(v1 => {
|
||||
g.createNode(v1.name);
|
||||
this.variables.forEach(v => {
|
||||
g.createNode(v.name);
|
||||
});
|
||||
|
||||
this.variables.forEach(v1 => {
|
||||
this.variables.forEach(v2 => {
|
||||
if (v1 === v2) {
|
||||
return;
|
||||
|
||||
@@ -22,6 +22,7 @@ const setup = (propOverrides?: object) => {
|
||||
updateUser: jest.fn(),
|
||||
removeUser: jest.fn(),
|
||||
setUsersSearchQuery: jest.fn(),
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@@ -41,6 +42,14 @@ describe('Render', () => {
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render List page', () => {
|
||||
const { wrapper } = setup({
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import Remarkable from 'remarkable';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import UsersActionBar from './UsersActionBar';
|
||||
import UsersTable from 'app/features/users/UsersTable';
|
||||
import UsersTable from './UsersTable';
|
||||
import InviteesTable from './InviteesTable';
|
||||
import { Invitee, NavModel, OrgUser } from 'app/types';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@@ -18,6 +19,7 @@ export interface Props {
|
||||
users: OrgUser[];
|
||||
searchQuery: string;
|
||||
externalUserMngInfo: string;
|
||||
hasFetched: boolean;
|
||||
loadUsers: typeof loadUsers;
|
||||
loadInvitees: typeof loadInvitees;
|
||||
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||
@@ -87,8 +89,24 @@ export class UsersListPage extends PureComponent<Props, State> {
|
||||
}));
|
||||
};
|
||||
|
||||
renderTable() {
|
||||
const { invitees, users } = this.props;
|
||||
|
||||
if (this.state.showInvites) {
|
||||
return <InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />;
|
||||
} else {
|
||||
return (
|
||||
<UsersTable
|
||||
users={users}
|
||||
onRoleChange={(role, user) => this.onRoleChange(role, user)}
|
||||
onRemoveUser={user => this.onRemoveUser(user)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { invitees, navModel, users } = this.props;
|
||||
const { navModel, hasFetched } = this.props;
|
||||
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
|
||||
|
||||
return (
|
||||
@@ -99,15 +117,7 @@ export class UsersListPage extends PureComponent<Props, State> {
|
||||
{externalUserMngInfoHtml && (
|
||||
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
|
||||
)}
|
||||
{this.state.showInvites ? (
|
||||
<InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />
|
||||
) : (
|
||||
<UsersTable
|
||||
users={users}
|
||||
onRoleChange={(role, user) => this.onRoleChange(role, user)}
|
||||
onRemoveUser={user => this.onRemoveUser(user)}
|
||||
/>
|
||||
)}
|
||||
{hasFetched ? this.renderTable() : <PageLoader pageName="Users" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -121,6 +131,7 @@ function mapStateToProps(state) {
|
||||
searchQuery: getUsersSearchQuery(state.users),
|
||||
invitees: getInvitees(state.users),
|
||||
externalUserMngInfo: state.users.externalUserMngInfo,
|
||||
hasFetched: state.users.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
exports[`Render should render List page 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
@@ -20,3 +20,22 @@ exports[`Render should render component 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(UsersActionBar)
|
||||
onShowInvites={[Function]}
|
||||
showInvites={false}
|
||||
/>
|
||||
<PageLoader
|
||||
pageName="Users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Invitee, OrgUser, UsersState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
import config from '../../../core/config';
|
||||
import config from 'app/core/config';
|
||||
|
||||
export const initialState: UsersState = {
|
||||
invitees: [] as Invitee[],
|
||||
@@ -10,15 +10,16 @@ export const initialState: UsersState = {
|
||||
externalUserMngInfo: config.externalUserMngInfo,
|
||||
externalUserMngLinkName: config.externalUserMngLinkName,
|
||||
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const usersReducer = (state = initialState, action: Action): UsersState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadUsers:
|
||||
return { ...state, users: action.payload };
|
||||
return { ...state, hasFetched: true, users: action.payload };
|
||||
|
||||
case ActionTypes.LoadInvitees:
|
||||
return { ...state, invitees: action.payload };
|
||||
return { ...state, hasFetched: true, invitees: action.payload };
|
||||
|
||||
case ActionTypes.SetUsersSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
@@ -44,8 +44,14 @@ export default class CloudWatchDatasource {
|
||||
|
||||
// valid ExtendedStatistics is like p90.00, check the pattern
|
||||
const hasInvalidStatistics = item.statistics.some(s => {
|
||||
return s.indexOf('p') === 0 && !/p\d{2}\.\d{2}/.test(s);
|
||||
if (s.indexOf('p') === 0) {
|
||||
const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s);
|
||||
return !matches || matches[0] !== s;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasInvalidStatistics) {
|
||||
throw { message: 'Invalid extended statistics' };
|
||||
}
|
||||
@@ -131,7 +137,7 @@ export default class CloudWatchDatasource {
|
||||
if (res.results) {
|
||||
_.forEach(res.results, queryRes => {
|
||||
_.forEach(queryRes.series, series => {
|
||||
data.push({ target: series.name, datapoints: series.points });
|
||||
data.push({ target: series.name, datapoints: series.points, unit: queryRes.meta.unit || 'none' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
Id
|
||||
<info-popover mode="right-normal ">Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover>
|
||||
</label>
|
||||
<input type="text " class="gf-form-input " ng-model="target.id " spellcheck='false' ng-pattern='/^[a-z][A-Z0-9_]*/' ng-model-onblur
|
||||
ng-change="onChange() ">
|
||||
<input type="text " class="gf-form-input " ng-model="target.id " spellcheck='false' ng-pattern='/^[a-z][a-zA-Z0-9_]*$/' ng-model-onblur ng-change="onChange() ">
|
||||
</div>
|
||||
<div class="gf-form max-width-30 ">
|
||||
<label class="gf-form-label query-keyword width-7 ">Expression</label>
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('CloudWatchDatasource', () => {
|
||||
A: {
|
||||
error: '',
|
||||
refId: 'A',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
name: 'CPUUtilization_Average',
|
||||
@@ -121,7 +122,7 @@ describe('CloudWatchDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel query for invalid extended statistics', () => {
|
||||
it.each(['pNN.NN', 'p9', 'p99.', 'p99.999'])('should cancel query for invalid extended statistics (%s)', stat => {
|
||||
const query = {
|
||||
range: { from: 'now-1h', to: 'now' },
|
||||
rangeRaw: { from: 1483228800, to: 1483232400 },
|
||||
@@ -133,7 +134,7 @@ describe('CloudWatchDatasource', () => {
|
||||
dimensions: {
|
||||
InstanceId: 'i-12345678',
|
||||
},
|
||||
statistics: ['pNN.NN'],
|
||||
statistics: [stat],
|
||||
period: '60s',
|
||||
},
|
||||
],
|
||||
@@ -221,6 +222,7 @@ describe('CloudWatchDatasource', () => {
|
||||
A: {
|
||||
error: '',
|
||||
refId: 'A',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
name: 'TargetResponseTime_p90.00',
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import { LogLevel } from 'app/core/logs_model';
|
||||
|
||||
import { getLogLevel, getSearchMatches } from './result_transformer';
|
||||
|
||||
describe('getSearchMatches()', () => {
|
||||
it('gets no matches for when search and or line are empty', () => {
|
||||
expect(getSearchMatches('', '')).toEqual([]);
|
||||
expect(getSearchMatches('foo', '')).toEqual([]);
|
||||
expect(getSearchMatches('', 'foo')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets no matches for unmatched search string', () => {
|
||||
expect(getSearchMatches('foo', 'bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets matches for matched search string', () => {
|
||||
expect(getSearchMatches('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo' }]);
|
||||
expect(getSearchMatches(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo' }]);
|
||||
});
|
||||
|
||||
expect(getSearchMatches(' foo foo bar ', 'foo|bar')).toEqual([
|
||||
{ length: 3, start: 1, text: 'foo' },
|
||||
{ length: 3, start: 5, text: 'foo' },
|
||||
{ length: 3, start: 9, text: 'bar' },
|
||||
]);
|
||||
});
|
||||
import { getLogLevel } from './result_transformer';
|
||||
|
||||
describe('getLoglevel()', () => {
|
||||
it('returns no log level on empty line', () => {
|
||||
|
||||
@@ -19,25 +19,6 @@ export function getLogLevel(line: string): LogLevel {
|
||||
return level;
|
||||
}
|
||||
|
||||
export function getSearchMatches(line: string, search: string) {
|
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!line || !search) {
|
||||
return [];
|
||||
}
|
||||
const regexp = new RegExp(`(?:${search})`, 'g');
|
||||
const matches = [];
|
||||
let match = regexp.exec(line);
|
||||
while (match) {
|
||||
matches.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
});
|
||||
match = regexp.exec(line);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow {
|
||||
const { line, timestamp } = entry;
|
||||
const { labels } = stream;
|
||||
@@ -45,16 +26,15 @@ export function processEntry(entry: { line: string; timestamp: string }, stream)
|
||||
const time = moment(timestamp);
|
||||
const timeFromNow = time.fromNow();
|
||||
const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
|
||||
const searchMatches = getSearchMatches(line, stream.search);
|
||||
const logLevel = getLogLevel(line);
|
||||
|
||||
return {
|
||||
key,
|
||||
logLevel,
|
||||
searchMatches,
|
||||
timeFromNow,
|
||||
timeLocal,
|
||||
entry: line,
|
||||
searchWords: [stream.search],
|
||||
timestamp: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b>Connection limits</b>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max open</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxOpenConns" placeholder="unlimited"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of open connections to the database. If <i>Max idle connections</i> is greater than 0 and the
|
||||
<i>Max open connections</i> is less than <i>Max idle connections</i>, then <i>Max idle connections</i> will be
|
||||
reduced to match the <i>Max open connections</i> limit. If set to 0, there is no limit on the number of open
|
||||
connections.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max idle</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxIdleConns" placeholder="2"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of connections in the idle connection pool. If <i>Max open connections</i> is greater than 0 but
|
||||
less than the <i>Max idle connections</i>, then the <i>Max idle connections</i> will be reduced to match the
|
||||
<i>Max open connections</i> limit. If set to 0, no idle connections are retained.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max lifetime</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.connMaxLifetime" placeholder="14400"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-heading">MSSQL details</h3>
|
||||
|
||||
<div class="gf-form-group">
|
||||
|
||||
@@ -24,6 +24,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b>Connection limits</b>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max open</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxOpenConns" placeholder="unlimited"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of open connections to the database. If <i>Max idle connections</i> is greater than 0 and the
|
||||
<i>Max open connections</i> is less than <i>Max idle connections</i>, then <i>Max idle connections</i> will be
|
||||
reduced to match the <i>Max open connections</i> limit. If set to 0, there is no limit on the number of open
|
||||
connections.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max idle</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxIdleConns" placeholder="2"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of connections in the idle connection pool. If <i>Max open connections</i> is greater than 0 but
|
||||
less than the <i>Max idle connections</i>, then the <i>Max idle connections</i> will be reduced to match the
|
||||
<i>Max open connections</i> limit. If set to 0, no idle connections are retained.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max lifetime</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.connMaxLifetime" placeholder="14400"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever.<br/><br/>
|
||||
This should always be lower than configured <a href="https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_wait_timeout" target="_blank">wait_timeout</a> in MySQL.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-heading">MySQL details</h3>
|
||||
|
||||
<div class="gf-form-group">
|
||||
|
||||
@@ -38,6 +38,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b>Connection limits</b>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max open</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxOpenConns" placeholder="unlimited"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of open connections to the database. If <i>Max idle connections</i> is greater than 0 and the
|
||||
<i>Max open connections</i> is less than <i>Max idle connections</i>, then <i>Max idle connections</i> will be
|
||||
reduced to match the <i>Max open connections</i> limit. If set to 0, there is no limit on the number of open
|
||||
connections.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max idle</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.maxIdleConns" placeholder="2"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum number of connections in the idle connection pool. If <i>Max open connections</i> is greater than 0 but
|
||||
less than the <i>Max idle connections</i>, then the <i>Max idle connections</i> will be reduced to match the
|
||||
<i>Max open connections</i> limit. If set to 0, no idle connections are retained.
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form max-width-15">
|
||||
<span class="gf-form-label width-7">Max lifetime</span>
|
||||
<input type="number" min="0" class="gf-form-input" ng-model="ctrl.current.jsonData.connMaxLifetime" placeholder="14400"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-heading">PostgreSQL details</h3>
|
||||
|
||||
<div class="gf-form-group">
|
||||
|
||||
@@ -77,6 +77,7 @@ function addLabelToSelector(selector: string, labelKey: string, labelValue: stri
|
||||
|
||||
// Sort labels by key and put them together
|
||||
return _.chain(parsedLabels)
|
||||
.uniqWith(_.isEqual)
|
||||
.compact()
|
||||
.sortBy('key')
|
||||
.map(({ key, operator, value }) => `${key}${operator}${value}`)
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class PrometheusMetricFindQuery {
|
||||
}
|
||||
|
||||
process() {
|
||||
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]+)\)\s*$/;
|
||||
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
|
||||
const metricNamesRegex = /^metrics\((.+)\)\s*$/;
|
||||
const queryResultRegex = /^query_result\((.+)\)\s*$/;
|
||||
|
||||
|
||||
@@ -40,4 +40,19 @@ describe('addLabelToQuery()', () => {
|
||||
'foo{bar="baz",x="yy"} * metric{a="bb",bar="baz",y="zz"} * metric2{bar="baz"}'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add duplicate labels to aquery', () => {
|
||||
expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe(
|
||||
'foo{bar!="baz",x="yy"}'
|
||||
);
|
||||
expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe(
|
||||
'rate(metric{foo="bar"}[1m])'
|
||||
);
|
||||
expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe(
|
||||
'foo{bar="baz",list="a,b,c"}'
|
||||
);
|
||||
expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe(
|
||||
'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export const alignOptions = [
|
||||
{
|
||||
text: 'delta',
|
||||
value: 'ALIGN_DELTA',
|
||||
valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
|
||||
valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
|
||||
metricKinds: [MetricKind.CUMULATIVE, MetricKind.DELTA],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -241,7 +241,17 @@ export default class StackdriverDatasource {
|
||||
try {
|
||||
const metricsApiPath = `v3/projects/${projectId}/metricDescriptors`;
|
||||
const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
|
||||
return data.metricDescriptors;
|
||||
|
||||
const metrics = data.metricDescriptors.map(m => {
|
||||
const [service] = m.type.split('/');
|
||||
const [serviceShortName] = service.split('.');
|
||||
m.service = service;
|
||||
m.serviceShortName = serviceShortName;
|
||||
m.displayName = m.displayName || m.type;
|
||||
return m;
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export class FilterSegments {
|
||||
}
|
||||
|
||||
// remove condition if it is first segment
|
||||
if (index === 0 && this.filterSegments[0].type === 'condition') {
|
||||
if (index === 0 && this.filterSegments.length > 0 && this.filterSegments[0].type === 'condition') {
|
||||
this.filterSegments.splice(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,21 +40,33 @@
|
||||
<div class="gf-form" ng-show="ctrl.showLastQuery">
|
||||
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.rawQueryString}}</pre>
|
||||
</div>
|
||||
<div class="gf-form grafana-info-box" style="padding: 0" ng-show="ctrl.showHelp">
|
||||
<pre class="gf-form-pre alert alert-info" style="margin-right: 0"><h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns.
|
||||
<div class="grafana-info-box m-t-2 markdown-html" ng-show="ctrl.showHelp">
|
||||
<h5>Alias Patterns</h5>
|
||||
|
||||
<label>Example: </label><code ng-non-bindable>{{metric.name}} - {{metric.label.instance_name}}</code>
|
||||
Format the legend keys any way you want by using alias patterns.<br /> <br />
|
||||
|
||||
<label>Result: </label><code ng-non-bindable>cpu/usage_time - server1-europe-west-1</code>
|
||||
Example: <code ng-non-bindable>{{metric.name}} - {{metric.label.instance_name}}</code><br />
|
||||
Result: <code ng-non-bindable>cpu/usage_time - server1-europe-west-1</code><br /><br />
|
||||
|
||||
<label>Patterns:</label>
|
||||
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
|
||||
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
|
||||
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
|
||||
|
||||
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g. metric.label.instance_name
|
||||
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
|
||||
</pre>
|
||||
<strong>Patterns</strong><br />
|
||||
<ul>
|
||||
<li>
|
||||
<code ng-non-bindable>{{metric.type}}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time
|
||||
</li>
|
||||
<li>
|
||||
<code ng-non-bindable>{{metric.name}}</code> = name part of metric e.g. instance/cpu/usage_time
|
||||
</li>
|
||||
<li>
|
||||
<code ng-non-bindable>{{metric.service}}</code> = service part of metric e.g. compute
|
||||
</li>
|
||||
<li>
|
||||
<code ng-non-bindable>{{metric.label.label_name}}</code> = Metric label metadata e.g.
|
||||
metric.label.instance_name
|
||||
</li>
|
||||
<li>
|
||||
<code ng-non-bindable>{{resource.label.label_name}}</code> = Resource label metadata e.g. resource.label.zone
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.lastQueryError">
|
||||
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
|
||||
|
||||
@@ -96,11 +96,9 @@ export class StackdriverFilterCtrl {
|
||||
getServicesList() {
|
||||
const defaultValue = { value: this.$scope.defaultServiceValue, text: this.$scope.defaultServiceValue };
|
||||
const services = this.metricDescriptors.map(m => {
|
||||
const [service] = m.type.split('/');
|
||||
const [serviceShortName] = service.split('.');
|
||||
return {
|
||||
value: service,
|
||||
text: serviceShortName,
|
||||
value: m.service,
|
||||
text: m.serviceShortName,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -113,12 +111,10 @@ export class StackdriverFilterCtrl {
|
||||
|
||||
getMetricsList() {
|
||||
const metrics = this.metricDescriptors.map(m => {
|
||||
const [service] = m.type.split('/');
|
||||
const [serviceShortName] = service.split('.');
|
||||
return {
|
||||
service,
|
||||
service: m.service,
|
||||
value: m.type,
|
||||
serviceShortName,
|
||||
serviceShortName: m.serviceShortName,
|
||||
text: m.displayName,
|
||||
title: m.description,
|
||||
};
|
||||
|
||||
@@ -164,11 +164,11 @@ describe('StackdriverDataSource', () => {
|
||||
metricDescriptors: [
|
||||
{
|
||||
displayName: 'test metric name 1',
|
||||
type: 'test metric type 1',
|
||||
type: 'compute.googleapis.com/instance/cpu/test-metric-type-1',
|
||||
description: 'A description',
|
||||
},
|
||||
{
|
||||
displayName: 'test metric name 2',
|
||||
type: 'test metric type 2',
|
||||
type: 'logging.googleapis.com/user/logbased-metric-with-no-display-name',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -180,8 +180,13 @@ describe('StackdriverDataSource', () => {
|
||||
});
|
||||
it('should return successfully', () => {
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('test metric type 1');
|
||||
expect(result[0].service).toBe('compute.googleapis.com');
|
||||
expect(result[0].serviceShortName).toBe('compute');
|
||||
expect(result[0].type).toBe('compute.googleapis.com/instance/cpu/test-metric-type-1');
|
||||
expect(result[0].displayName).toBe('test metric name 1');
|
||||
expect(result[0].description).toBe('A description');
|
||||
expect(result[1].type).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name');
|
||||
expect(result[1].displayName).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -713,7 +713,9 @@ class GraphElement {
|
||||
if (min && max && ticks) {
|
||||
const range = max - min;
|
||||
const secPerTick = range / ticks / 1000;
|
||||
const oneDay = 86400000;
|
||||
// Need have 10 milisecond margin on the day range
|
||||
// As sometimes last 24 hour dashboard evaluates to more than 86400000
|
||||
const oneDay = 86400010;
|
||||
const oneYear = 31536000000;
|
||||
|
||||
if (secPerTick <= 45) {
|
||||
|
||||
@@ -156,7 +156,16 @@ class GraphCtrl extends MetricsPanelCtrl {
|
||||
panel: this.panel,
|
||||
range: this.range,
|
||||
});
|
||||
return super.issueQueries(datasource);
|
||||
|
||||
/* Wait for annotationSrv requests to get datasources to
|
||||
* resolve before issuing queries. This allows the annotations
|
||||
* service to fire annotations queries before graph queries
|
||||
* (but not wait for completion). This resolves
|
||||
* issue 11806.
|
||||
*/
|
||||
return this.annotationsSrv.datasourcePromises.then(r => {
|
||||
return super.issueQueries(datasource);
|
||||
});
|
||||
}
|
||||
|
||||
zoomOut(evt) {
|
||||
|
||||
15
public/app/routes/registry.ts
Normal file
15
public/app/routes/registry.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
interface RegisterRoutesHandler {
|
||||
($routeProvider): any;
|
||||
}
|
||||
|
||||
const handlers: RegisterRoutesHandler[] = [];
|
||||
|
||||
export function applyRouteRegistrationHandlers($routeProvider) {
|
||||
for (const handler of handlers) {
|
||||
handler($routeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
export function addRouteRegistrationHandler(fn: RegisterRoutesHandler) {
|
||||
handlers.push(fn);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import './dashboard_loaders';
|
||||
import './ReactContainer';
|
||||
import { applyRouteRegistrationHandlers } from './registry';
|
||||
|
||||
import ServerStats from 'app/features/admin/ServerStats';
|
||||
import AlertRuleList from 'app/features/alerting/AlertRuleList';
|
||||
@@ -12,7 +13,6 @@ import FolderPermissions from 'app/features/folders/FolderPermissions';
|
||||
import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
|
||||
import NewDataSourcePage from '../features/datasources/NewDataSourcePage';
|
||||
import UsersListPage from 'app/features/users/UsersListPage';
|
||||
import EditDataSourcePage from 'app/features/datasources/EditDataSourcePage';
|
||||
|
||||
/** @ngInject */
|
||||
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
@@ -82,12 +82,6 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controller: 'DataSourceDashboardsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/datasources/edit/:id/:page?', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => EditDataSourcePage,
|
||||
},
|
||||
})
|
||||
.when('/datasources/new', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
@@ -313,4 +307,6 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
templateUrl: 'public/app/partials/error.html',
|
||||
controller: 'ErrorCtrl',
|
||||
});
|
||||
|
||||
applyRouteRegistrationHandlers($routeProvider);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user