Merge branch 'master' into permissions-code-to-enterprise

This commit is contained in:
Peter Holmberg
2018-10-16 11:09:58 +02:00
215 changed files with 16653 additions and 2684 deletions

View File

@@ -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(() => {

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

@@ -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',

View File

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

View 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: () => ({}),
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ Array [
onClick={[Function]}
>
<img
alt="graphana_logo"
alt="Grafana"
src="public/img/grafana_icon.svg"
/>
</div>,

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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' },
],
},
{

View 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 },
]);
});

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {
&nbsp;
</div>
)}
<div className="dashboard-row__drag grid-drag-handle" />
{canEdit && <div className="dashboard-row__drag grid-drag-handle" />}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`)

View File

@@ -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*$/;

View File

@@ -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"})'
);
});
});

View File

@@ -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],
},
{

View File

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

View File

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

View File

@@ -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: &nbsp;&nbsp;<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>

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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) {

View 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);
}

View File

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