mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into new-data-source-as-separate-page
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import OrgActionBar, { Props } from './OrgActionBar';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
searchQuery: '',
|
||||
setSearchQuery: jest.fn(),
|
||||
target: '_blank',
|
||||
linkButton: { href: 'some/url', title: 'test' },
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<OrgActionBar {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
44
public/app/core/components/OrgActionBar/OrgActionBar.tsx
Normal file
44
public/app/core/components/OrgActionBar/OrgActionBar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
|
||||
|
||||
export interface Props {
|
||||
searchQuery: string;
|
||||
layoutMode?: LayoutMode;
|
||||
onSetLayoutMode?: (mode: LayoutMode) => {};
|
||||
setSearchQuery: (value: string) => {};
|
||||
linkButton: { href: string; title: string };
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export default class OrgActionBar extends PureComponent<Props> {
|
||||
render() {
|
||||
const { searchQuery, layoutMode, onSetLayoutMode, linkButton, setSearchQuery, target } = this.props;
|
||||
const linkProps = { href: linkButton.href, target: undefined };
|
||||
|
||||
if (target) {
|
||||
linkProps.target = target;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-20"
|
||||
value={searchQuery}
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Filter by name or type"
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => onSetLayoutMode(mode)} />
|
||||
</div>
|
||||
<div className="page-action-bar__spacer" />
|
||||
<a className="btn btn-success" {...linkProps}>
|
||||
{linkButton.title}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
|
||||
/>
|
||||
</label>
|
||||
<LayoutSelector
|
||||
mode="grid"
|
||||
onLayoutModeChanged={[Function]}
|
||||
/>
|
||||
</div>
|
||||
@@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
|
||||
/>
|
||||
<a
|
||||
className="btn btn-success"
|
||||
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
|
||||
href="some/url"
|
||||
target="_blank"
|
||||
>
|
||||
Find more plugins on Grafana.com
|
||||
test
|
||||
</a>
|
||||
</div>
|
||||
`;
|
@@ -10,6 +10,7 @@ import colors from 'app/core/utils/colors';
|
||||
import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
|
||||
|
||||
export class GrafanaCtrl {
|
||||
/** @ngInject */
|
||||
@@ -22,11 +23,13 @@ export class GrafanaCtrl {
|
||||
contextSrv,
|
||||
bridgeSrv,
|
||||
backendSrv: BackendSrv,
|
||||
datasourceSrv: DatasourceSrv
|
||||
datasourceSrv: DatasourceSrv,
|
||||
angularLoader: AngularLoader
|
||||
) {
|
||||
// sets singleston instances for angular services so react components can access them
|
||||
configureStore();
|
||||
setAngularLoader(angularLoader);
|
||||
setBackendSrv(backendSrv);
|
||||
configureStore();
|
||||
|
||||
$scope.init = () => {
|
||||
$scope.contextSrv = contextSrv;
|
||||
|
42
public/app/core/services/AngularLoader.ts
Normal file
42
public/app/core/services/AngularLoader.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import angular from 'angular';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
export interface AngularComponent {
|
||||
destroy();
|
||||
}
|
||||
|
||||
export class AngularLoader {
|
||||
/** @ngInject */
|
||||
constructor(private $compile, private $rootScope) {}
|
||||
|
||||
load(elem, scopeProps, template): AngularComponent {
|
||||
const scope = this.$rootScope.$new();
|
||||
|
||||
_.assign(scope, scopeProps);
|
||||
|
||||
const compiledElem = this.$compile(template)(scope);
|
||||
const rootNode = angular.element(elem);
|
||||
rootNode.append(compiledElem);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
scope.$destroy();
|
||||
compiledElem.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.service('angularLoader', AngularLoader);
|
||||
|
||||
let angularLoaderInstance: AngularLoader;
|
||||
|
||||
export function setAngularLoader(pl: AngularLoader) {
|
||||
angularLoaderInstance = pl;
|
||||
}
|
||||
|
||||
// away to access it from react
|
||||
export function getAngularLoader(): AngularLoader {
|
||||
return angularLoaderInstance;
|
||||
}
|
@@ -4,7 +4,7 @@ import _ from 'lodash';
|
||||
import config from 'app/core/config';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { renderUrl } from 'app/core/utils/url';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
|
||||
import Mousetrap from 'mousetrap';
|
||||
import 'mousetrap-global-bind';
|
||||
@@ -15,7 +15,14 @@ export class KeybindingSrv {
|
||||
timepickerOpen = false;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv, private contextSrv) {
|
||||
constructor(
|
||||
private $rootScope,
|
||||
private $location,
|
||||
private $timeout,
|
||||
private datasourceSrv,
|
||||
private timeSrv,
|
||||
private contextSrv
|
||||
) {
|
||||
// clear out all shortcuts on route change
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
Mousetrap.reset();
|
||||
@@ -194,14 +201,9 @@ export class KeybindingSrv {
|
||||
if (dashboard.meta.focusPanelId) {
|
||||
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
|
||||
const datasource = await this.datasourceSrv.get(panel.datasource);
|
||||
if (datasource && datasource.supportsExplore) {
|
||||
const range = this.timeSrv.timeRangeForUrl();
|
||||
const state = {
|
||||
...datasource.getExploreState(panel),
|
||||
range,
|
||||
};
|
||||
const exploreState = JSON.stringify(state);
|
||||
this.$location.url(renderUrl('/explore', { state: exploreState }));
|
||||
const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
|
||||
if (url) {
|
||||
this.$timeout(() => this.$location.url(url));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { serializeStateToUrlParam, parseUrlState } from './Wrapper';
|
||||
import { DEFAULT_RANGE } from './TimePicker';
|
||||
import { ExploreState } from './Explore';
|
||||
import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore';
|
||||
import { ExploreState } from 'app/types/explore';
|
||||
|
||||
const DEFAULT_EXPLORE_STATE: ExploreState = {
|
||||
datasource: null,
|
||||
@@ -8,6 +7,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
|
||||
datasourceLoading: null,
|
||||
datasourceMissing: false,
|
||||
datasourceName: '',
|
||||
exploreDatasources: [],
|
||||
graphResult: null,
|
||||
history: [],
|
||||
latency: 0,
|
||||
@@ -27,7 +27,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
|
||||
tableResult: null,
|
||||
};
|
||||
|
||||
describe('Wrapper state functions', () => {
|
||||
describe('state functions', () => {
|
||||
describe('parseUrlState', () => {
|
||||
it('returns default state on empty string', () => {
|
||||
expect(parseUrlState('')).toMatchObject({
|
||||
@@ -57,7 +57,7 @@ describe('Wrapper 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"}}'
|
||||
);
|
||||
});
|
||||
});
|
78
public/app/core/utils/explore.ts
Normal file
78
public/app/core/utils/explore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { renderUrl } from 'app/core/utils/url';
|
||||
import { ExploreState, ExploreUrlState } from 'app/types/explore';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
|
||||
*
|
||||
* @param panel Origin panel of the jump to Explore
|
||||
* @param panelTargets The origin panel's query targets
|
||||
* @param panelDatasource The origin panel's datasource
|
||||
* @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
|
||||
* @param timeSrv Time service to get the current dashboard range from
|
||||
*/
|
||||
export async function getExploreUrl(
|
||||
panel: any,
|
||||
panelTargets: any[],
|
||||
panelDatasource: any,
|
||||
datasourceSrv: any,
|
||||
timeSrv: any
|
||||
) {
|
||||
let exploreDatasource = panelDatasource;
|
||||
let exploreTargets = panelTargets;
|
||||
let url;
|
||||
|
||||
// Mixed datasources need to choose only one datasource
|
||||
if (panelDatasource.meta.id === 'mixed' && panelTargets) {
|
||||
// Find first explore datasource among targets
|
||||
let mixedExploreDatasource;
|
||||
for (const t of panel.targets) {
|
||||
const datasource = await datasourceSrv.get(t.datasource);
|
||||
if (datasource && datasource.meta.explore) {
|
||||
mixedExploreDatasource = datasource;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add all its targets
|
||||
if (mixedExploreDatasource) {
|
||||
exploreDatasource = mixedExploreDatasource;
|
||||
exploreTargets = panelTargets.filter(t => t.datasource === mixedExploreDatasource.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (exploreDatasource && exploreDatasource.meta.explore) {
|
||||
const range = timeSrv.timeRangeForUrl();
|
||||
const state = {
|
||||
...exploreDatasource.getExploreState(exploreTargets),
|
||||
range,
|
||||
};
|
||||
const exploreState = JSON.stringify(state);
|
||||
url = renderUrl('/explore', { state: exploreState });
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
if (initial) {
|
||||
try {
|
||||
return JSON.parse(decodeURI(initial));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
export function serializeStateToUrlParam(state: ExploreState): string {
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: state.datasourceName,
|
||||
queries: state.queries.map(q => ({ query: q.query })),
|
||||
range: state.range,
|
||||
};
|
||||
return JSON.stringify(urlState);
|
||||
}
|
@@ -1,10 +1,5 @@
|
||||
import config from 'app/core/config';
|
||||
|
||||
// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
|
||||
const SLASH = '<SLASH>';
|
||||
export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
|
||||
export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
|
||||
|
||||
export const stripBaseFromUrl = url => {
|
||||
const appSubUrl = config.appSubUrl;
|
||||
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
|
||||
|
@@ -58,7 +58,7 @@ export function updateDashboardPermission(
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = toUpdateItem(itemToUpdate);
|
||||
const updated = toUpdateItem(item);
|
||||
|
||||
// if this is the item we want to update, update it's permisssion
|
||||
if (itemToUpdate === item) {
|
||||
|
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
|
||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
layoutMode: LayoutModes.Grid,
|
||||
searchQuery: '',
|
||||
setDataSourcesLayoutMode: jest.fn(),
|
||||
setDataSourcesSearchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
return shallow(<DataSourcesActionBar {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
@@ -1,62 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
|
||||
import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
searchQuery: string;
|
||||
layoutMode: LayoutMode;
|
||||
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
||||
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
||||
}
|
||||
|
||||
export class DataSourcesActionBar extends PureComponent<Props> {
|
||||
onSearchQueryChange = event => {
|
||||
this.props.setDataSourcesSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props;
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-20"
|
||||
value={searchQuery}
|
||||
onChange={this.onSearchQueryChange}
|
||||
placeholder="Filter by name or type"
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
<LayoutSelector
|
||||
mode={layoutMode}
|
||||
onLayoutModeChanged={(mode: LayoutMode) => setDataSourcesLayoutMode(mode)}
|
||||
/>
|
||||
</div>
|
||||
<div className="page-action-bar__spacer" />
|
||||
<a className="page-header__cta btn btn-success" href="datasources/new">
|
||||
<i className="fa fa-plus" />
|
||||
Add data source
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setDataSourcesLayoutMode,
|
||||
setDataSourcesSearchQuery,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar);
|
@@ -12,6 +12,9 @@ const setup = (propOverrides?: object) => {
|
||||
loadDataSources: jest.fn(),
|
||||
navModel: {} as NavModel,
|
||||
dataSourcesCount: 0,
|
||||
searchQuery: '',
|
||||
setDataSourcesSearchQuery: jest.fn(),
|
||||
setDataSourcesLayoutMode: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||
import DataSourcesActionBar from './DataSourcesActionBar';
|
||||
import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
|
||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
import { loadDataSources } from './state/actions';
|
||||
import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import { DataSource, NavModel } from 'app/types';
|
||||
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import {
|
||||
getDataSources,
|
||||
getDataSourcesCount,
|
||||
getDataSourcesLayoutMode,
|
||||
getDataSourcesSearchQuery,
|
||||
} from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
dataSources: DataSource[];
|
||||
dataSourcesCount: number;
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
loadDataSources: typeof loadDataSources;
|
||||
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
||||
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
||||
}
|
||||
|
||||
const emptyListModel = {
|
||||
@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props;
|
||||
const {
|
||||
dataSources,
|
||||
dataSourcesCount,
|
||||
navModel,
|
||||
layoutMode,
|
||||
searchQuery,
|
||||
setDataSourcesSearchQuery,
|
||||
setDataSourcesLayoutMode,
|
||||
} = this.props;
|
||||
|
||||
const linkButton = {
|
||||
href: 'datasources/new',
|
||||
title: 'Add data source',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
<EmptyListCTA model={emptyListModel} />
|
||||
) : (
|
||||
[
|
||||
<DataSourcesActionBar key="action-bar" />,
|
||||
<OrgActionBar
|
||||
layoutMode={layoutMode}
|
||||
searchQuery={searchQuery}
|
||||
onSetLayoutMode={mode => setDataSourcesLayoutMode(mode)}
|
||||
setSearchQuery={query => setDataSourcesSearchQuery(query)}
|
||||
linkButton={linkButton}
|
||||
key="action-bar"
|
||||
/>,
|
||||
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
||||
]
|
||||
)}
|
||||
@@ -66,11 +94,14 @@ function mapStateToProps(state) {
|
||||
dataSources: getDataSources(state.dataSources),
|
||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
||||
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadDataSources,
|
||||
setDataSourcesSearchQuery,
|
||||
setDataSourcesLayoutMode,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
|
||||
|
@@ -1,42 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input width-20"
|
||||
onChange={[Function]}
|
||||
placeholder="Filter by name or type"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
<LayoutSelector
|
||||
mode="grid"
|
||||
onLayoutModeChanged={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<a
|
||||
className="page-header__cta btn btn-success"
|
||||
href="datasources/new"
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
Add data source
|
||||
</a>
|
||||
</div>
|
||||
`;
|
@@ -8,8 +8,18 @@ exports[`Render should render action bar and datasources 1`] = `
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(DataSourcesActionBar)
|
||||
<OrgActionBar
|
||||
key="action-bar"
|
||||
layoutMode="grid"
|
||||
linkButton={
|
||||
Object {
|
||||
"href": "datasources/new",
|
||||
"title": "Add data source",
|
||||
}
|
||||
}
|
||||
onSetLayoutMode={[Function]}
|
||||
searchQuery=""
|
||||
setSearchQuery={[Function]}
|
||||
/>
|
||||
<DataSourcesList
|
||||
dataSources={
|
||||
|
@@ -2,19 +2,20 @@ import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import Select from 'react-select';
|
||||
|
||||
import { Query, Range, ExploreUrlState } from 'app/types/explore';
|
||||
import { ExploreState, ExploreUrlState, Query } from 'app/types/explore';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import colors from 'app/core/utils/colors';
|
||||
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 ElapsedTime from './ElapsedTime';
|
||||
import QueryRows from './QueryRows';
|
||||
import Graph from './Graph';
|
||||
import Logs from './Logs';
|
||||
import Table from './Table';
|
||||
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
|
||||
import TimePicker from './TimePicker';
|
||||
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 100;
|
||||
@@ -58,64 +59,52 @@ interface ExploreProps {
|
||||
urlState: ExploreUrlState;
|
||||
}
|
||||
|
||||
export interface ExploreState {
|
||||
datasource: any;
|
||||
datasourceError: any;
|
||||
datasourceLoading: boolean | null;
|
||||
datasourceMissing: boolean;
|
||||
datasourceName?: string;
|
||||
graphResult: any;
|
||||
history: any[];
|
||||
latency: number;
|
||||
loading: any;
|
||||
logsResult: any;
|
||||
queries: Query[];
|
||||
queryErrors: any[];
|
||||
queryHints: any[];
|
||||
range: Range;
|
||||
requestOptions: any;
|
||||
showingGraph: boolean;
|
||||
showingLogs: boolean;
|
||||
showingTable: boolean;
|
||||
supportsGraph: boolean | null;
|
||||
supportsLogs: boolean | null;
|
||||
supportsTable: boolean | null;
|
||||
tableResult: any;
|
||||
}
|
||||
|
||||
export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
el: any;
|
||||
/**
|
||||
* Current query expressions of the rows including their modifications, used for running queries.
|
||||
* Not kept in component state to prevent edit-render roundtrips.
|
||||
*/
|
||||
queryExpressions: string[];
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Split state overrides everything
|
||||
const splitState: ExploreState = props.splitState;
|
||||
const { datasource, queries, range } = props.urlState;
|
||||
this.state = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: null,
|
||||
datasourceMissing: false,
|
||||
datasourceName: datasource,
|
||||
graphResult: null,
|
||||
history: [],
|
||||
latency: 0,
|
||||
loading: false,
|
||||
logsResult: null,
|
||||
queries: ensureQueries(queries),
|
||||
queryErrors: [],
|
||||
queryHints: [],
|
||||
range: range || { ...DEFAULT_RANGE },
|
||||
requestOptions: null,
|
||||
showingGraph: true,
|
||||
showingLogs: true,
|
||||
showingTable: true,
|
||||
supportsGraph: null,
|
||||
supportsLogs: null,
|
||||
supportsTable: null,
|
||||
tableResult: null,
|
||||
...splitState,
|
||||
};
|
||||
let initialQueries: Query[];
|
||||
if (splitState) {
|
||||
// Split state overrides everything
|
||||
this.state = splitState;
|
||||
initialQueries = splitState.queries;
|
||||
} else {
|
||||
const { datasource, queries, range } = props.urlState as ExploreUrlState;
|
||||
initialQueries = ensureQueries(queries);
|
||||
this.state = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: null,
|
||||
datasourceMissing: false,
|
||||
datasourceName: datasource,
|
||||
exploreDatasources: [],
|
||||
graphResult: null,
|
||||
history: [],
|
||||
latency: 0,
|
||||
loading: false,
|
||||
logsResult: null,
|
||||
queries: initialQueries,
|
||||
queryErrors: [],
|
||||
queryHints: [],
|
||||
range: range || { ...DEFAULT_RANGE },
|
||||
requestOptions: null,
|
||||
showingGraph: true,
|
||||
showingLogs: true,
|
||||
showingTable: true,
|
||||
supportsGraph: null,
|
||||
supportsLogs: null,
|
||||
supportsTable: null,
|
||||
tableResult: null,
|
||||
};
|
||||
}
|
||||
this.queryExpressions = initialQueries.map(q => q.query);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
@@ -125,8 +114,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
throw new Error('No datasource service passed as props.');
|
||||
}
|
||||
const datasources = datasourceSrv.getExploreSources();
|
||||
const exploreDatasources = datasources.map(ds => ({
|
||||
value: ds.name,
|
||||
label: ds.name,
|
||||
}));
|
||||
|
||||
if (datasources.length > 0) {
|
||||
this.setState({ datasourceLoading: true });
|
||||
this.setState({ datasourceLoading: true, exploreDatasources });
|
||||
// Priority: datasource in url, default datasource, first explore datasource
|
||||
let datasource;
|
||||
if (datasourceName) {
|
||||
@@ -170,9 +164,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
// Keep queries but reset edit state
|
||||
const nextQueries = this.state.queries.map(q => ({
|
||||
const nextQueries = this.state.queries.map((q, i) => ({
|
||||
...q,
|
||||
edited: false,
|
||||
key: generateQueryKey(i),
|
||||
query: this.queryExpressions[i],
|
||||
}));
|
||||
|
||||
this.setState(
|
||||
@@ -201,6 +196,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
|
||||
onAddQueryRow = index => {
|
||||
const { queries } = this.state;
|
||||
this.queryExpressions[index + 1] = '';
|
||||
const nextQueries = [
|
||||
...queries.slice(0, index + 1),
|
||||
{ query: '', key: generateQueryKey() },
|
||||
@@ -227,29 +223,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
};
|
||||
|
||||
onChangeQuery = (value: string, index: number, override?: boolean) => {
|
||||
const { queries } = this.state;
|
||||
let { queryErrors, queryHints } = this.state;
|
||||
const prevQuery = queries[index];
|
||||
const edited = override ? false : prevQuery.query !== value;
|
||||
const nextQuery = {
|
||||
...queries[index],
|
||||
edited,
|
||||
query: value,
|
||||
};
|
||||
const nextQueries = [...queries];
|
||||
nextQueries[index] = nextQuery;
|
||||
// Keep current value in local cache
|
||||
this.queryExpressions[index] = value;
|
||||
|
||||
// Replace query row on override
|
||||
if (override) {
|
||||
queryErrors = [];
|
||||
queryHints = [];
|
||||
const { queries } = this.state;
|
||||
const nextQuery: Query = {
|
||||
key: generateQueryKey(index),
|
||||
query: value,
|
||||
};
|
||||
const nextQueries = [...queries];
|
||||
nextQueries[index] = nextQuery;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
queryErrors: [],
|
||||
queryHints: [],
|
||||
queries: nextQueries,
|
||||
},
|
||||
this.onSubmit
|
||||
);
|
||||
}
|
||||
this.setState(
|
||||
{
|
||||
queryErrors,
|
||||
queryHints,
|
||||
queries: nextQueries,
|
||||
},
|
||||
override ? () => this.onSubmit() : undefined
|
||||
);
|
||||
};
|
||||
|
||||
onChangeTime = nextRange => {
|
||||
@@ -261,6 +256,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
};
|
||||
|
||||
onClickClear = () => {
|
||||
this.queryExpressions = [''];
|
||||
this.setState(
|
||||
{
|
||||
graphResult: null,
|
||||
@@ -293,9 +289,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
|
||||
onClickSplit = () => {
|
||||
const { onChangeSplit } = this.props;
|
||||
const state = { ...this.state };
|
||||
state.queries = state.queries.map(({ edited, ...rest }) => rest);
|
||||
if (onChangeSplit) {
|
||||
const state = this.cloneState();
|
||||
onChangeSplit(true, state);
|
||||
this.saveState();
|
||||
}
|
||||
@@ -315,23 +310,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
let nextQueries;
|
||||
if (index === undefined) {
|
||||
// Modify all queries
|
||||
nextQueries = queries.map(q => ({
|
||||
...q,
|
||||
edited: false,
|
||||
query: datasource.modifyQuery(q.query, action),
|
||||
nextQueries = queries.map((q, i) => ({
|
||||
key: generateQueryKey(i),
|
||||
query: datasource.modifyQuery(this.queryExpressions[i], action),
|
||||
}));
|
||||
} else {
|
||||
// Modify query only at index
|
||||
nextQueries = [
|
||||
...queries.slice(0, index),
|
||||
{
|
||||
...queries[index],
|
||||
edited: false,
|
||||
query: datasource.modifyQuery(queries[index].query, action),
|
||||
key: generateQueryKey(index),
|
||||
query: datasource.modifyQuery(this.queryExpressions[index], action),
|
||||
},
|
||||
...queries.slice(index + 1),
|
||||
];
|
||||
}
|
||||
this.queryExpressions = nextQueries.map(q => q.query);
|
||||
this.setState({ queries: nextQueries }, () => this.onSubmit());
|
||||
}
|
||||
};
|
||||
@@ -342,6 +336,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
return;
|
||||
}
|
||||
const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
|
||||
this.queryExpressions = nextQueries.map(q => q.query);
|
||||
this.setState({ queries: nextQueries }, () => this.onSubmit());
|
||||
};
|
||||
|
||||
@@ -359,7 +354,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
this.saveState();
|
||||
};
|
||||
|
||||
onQuerySuccess(datasourceId: string, queries: any[]): void {
|
||||
onQuerySuccess(datasourceId: string, queries: string[]): void {
|
||||
// save queries to history
|
||||
let { history } = this.state;
|
||||
const { datasource } = this.state;
|
||||
@@ -370,8 +365,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
const ts = Date.now();
|
||||
queries.forEach(q => {
|
||||
const { query } = q;
|
||||
queries.forEach(query => {
|
||||
history = [{ query, ts }, ...history];
|
||||
});
|
||||
|
||||
@@ -386,16 +380,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
|
||||
const { datasource, queries, range } = this.state;
|
||||
const { datasource, range } = this.state;
|
||||
const resolution = this.el.offsetWidth;
|
||||
const absoluteRange = {
|
||||
from: parseDate(range.from, false),
|
||||
to: parseDate(range.to, true),
|
||||
};
|
||||
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
|
||||
const targets = queries.map(q => ({
|
||||
const targets = this.queryExpressions.map(q => ({
|
||||
...targetOptions,
|
||||
expr: q.query,
|
||||
expr: q,
|
||||
}));
|
||||
return {
|
||||
interval,
|
||||
@@ -405,7 +399,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
async runGraphQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
const { datasource } = this.state;
|
||||
const queries = [...this.queryExpressions];
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
@@ -427,7 +422,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
async runTableQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
const queries = [...this.queryExpressions];
|
||||
const { datasource } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
@@ -451,7 +447,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
}
|
||||
|
||||
async runLogsQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
const queries = [...this.queryExpressions];
|
||||
const { datasource } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
@@ -479,18 +476,27 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
return datasource.metadataRequest(url);
|
||||
};
|
||||
|
||||
cloneState(): ExploreState {
|
||||
// Copy state, but copy queries including modifications
|
||||
return {
|
||||
...this.state,
|
||||
queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
|
||||
};
|
||||
}
|
||||
|
||||
saveState = () => {
|
||||
const { stateKey, onSaveState } = this.props;
|
||||
onSaveState(stateKey, this.state);
|
||||
onSaveState(stateKey, this.cloneState());
|
||||
};
|
||||
|
||||
render() {
|
||||
const { datasourceSrv, position, split } = this.props;
|
||||
const { position, split } = this.props;
|
||||
const {
|
||||
datasource,
|
||||
datasourceError,
|
||||
datasourceLoading,
|
||||
datasourceMissing,
|
||||
exploreDatasources,
|
||||
graphResult,
|
||||
history,
|
||||
latency,
|
||||
@@ -515,10 +521,6 @@ 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 datasources = datasourceSrv.getExploreSources().map(ds => ({
|
||||
value: ds.name,
|
||||
label: ds.name,
|
||||
}));
|
||||
const selectedDatasource = datasource ? datasource.name : undefined;
|
||||
|
||||
return (
|
||||
@@ -544,7 +546,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
clearable={false}
|
||||
className="gf-form-input gf-form-input--form-dropdown datasource-picker"
|
||||
onChange={this.onChangeDatasource}
|
||||
options={datasources}
|
||||
options={exploreDatasources}
|
||||
isOpen={true}
|
||||
placeholder="Loading datasources..."
|
||||
value={selectedDatasource}
|
||||
|
@@ -156,6 +156,7 @@ interface PromQueryFieldState {
|
||||
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
logLabelOptions: any[];
|
||||
metrics: string[];
|
||||
metricsOptions: any[];
|
||||
metricsByPrefix: CascaderOption[];
|
||||
}
|
||||
|
||||
@@ -167,7 +168,7 @@ interface PromTypeaheadInput {
|
||||
value?: Value;
|
||||
}
|
||||
|
||||
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
|
||||
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
|
||||
plugins: any[];
|
||||
|
||||
constructor(props: PromQueryFieldProps, context) {
|
||||
@@ -189,6 +190,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
|
||||
logLabelOptions: [],
|
||||
metrics: props.metrics || [],
|
||||
metricsByPrefix: props.metricsByPrefix || [],
|
||||
metricsOptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,10 +260,22 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
|
||||
};
|
||||
|
||||
onReceiveMetrics = () => {
|
||||
if (!this.state.metrics) {
|
||||
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global prism config
|
||||
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
|
||||
|
||||
// Build metrics tree
|
||||
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
||||
const metricsOptions = [
|
||||
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
|
||||
...metricsByPrefix,
|
||||
];
|
||||
|
||||
this.setState({ metricsOptions });
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
@@ -453,7 +467,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
|
||||
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
|
||||
if (histogramSeries && histogramSeries['__name__']) {
|
||||
const histogramMetrics = histogramSeries['__name__'].slice().sort();
|
||||
this.setState({ histogramMetrics });
|
||||
this.setState({ histogramMetrics }, this.onReceiveMetrics);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -545,12 +559,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
|
||||
|
||||
render() {
|
||||
const { error, hint, supportsLogs } = this.props;
|
||||
const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state;
|
||||
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
||||
const metricsOptions = [
|
||||
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
|
||||
...metricsByPrefix,
|
||||
];
|
||||
const { logLabelOptions, metricsOptions } = this.state;
|
||||
|
||||
return (
|
||||
<div className="prom-query-field">
|
||||
@@ -575,6 +584,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
portalPrefix="prometheus"
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
|
@@ -11,10 +11,17 @@ import NewlinePlugin from './slate-plugins/newline';
|
||||
import Typeahead from './Typeahead';
|
||||
import { makeFragment, makeValue } from './Value';
|
||||
|
||||
export const TYPEAHEAD_DEBOUNCE = 300;
|
||||
export const TYPEAHEAD_DEBOUNCE = 100;
|
||||
|
||||
function flattenSuggestions(s: any[]): any[] {
|
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
||||
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
|
||||
// Flatten suggestion groups
|
||||
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
|
||||
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
|
||||
return flattenedSuggestions[correctedIndex];
|
||||
}
|
||||
|
||||
function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
|
||||
return suggestions && suggestions.length > 0;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
@@ -125,7 +132,7 @@ export interface TypeaheadOutput {
|
||||
suggestions: SuggestionGroup[];
|
||||
}
|
||||
|
||||
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
|
||||
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
|
||||
menuEl: HTMLElement | null;
|
||||
plugins: any[];
|
||||
resetTimer: any;
|
||||
@@ -154,8 +161,14 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateMenu();
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Only update menu location when suggestion existence or text/selection changed
|
||||
if (
|
||||
this.state.value !== prevState.value ||
|
||||
hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
|
||||
) {
|
||||
this.updateMenu();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
@@ -166,15 +179,21 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
}
|
||||
|
||||
onChange = ({ value }) => {
|
||||
const changed = value.document !== this.state.value.document;
|
||||
const textChanged = value.document !== this.state.value.document;
|
||||
|
||||
// Control editor loop, then pass text change up to parent
|
||||
this.setState({ value }, () => {
|
||||
if (changed) {
|
||||
if (textChanged) {
|
||||
this.handleChangeValue();
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
// Show suggest menu on text input
|
||||
if (textChanged && value.selection.isCollapsed) {
|
||||
// Need one paint to allow DOM-based typeahead rules to work
|
||||
window.requestAnimationFrame(this.handleTypeahead);
|
||||
} else {
|
||||
this.resetTypeahead();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,7 +235,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
wrapperNode,
|
||||
});
|
||||
|
||||
const filteredSuggestions = suggestions
|
||||
let filteredSuggestions = suggestions
|
||||
.map(group => {
|
||||
if (group.items) {
|
||||
if (prefix) {
|
||||
@@ -241,6 +260,11 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
})
|
||||
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
|
||||
|
||||
// Keep same object for equality checking later
|
||||
if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
|
||||
filteredSuggestions = this.state.suggestions;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
suggestions: filteredSuggestions,
|
||||
@@ -326,12 +350,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
const selected = Math.abs(typeaheadIndex);
|
||||
const selectedIndex = selected % flattenedSuggestions.length || 0;
|
||||
const suggestion = flattenedSuggestions[selectedIndex];
|
||||
|
||||
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
|
||||
this.applyTypeahead(change, suggestion);
|
||||
return true;
|
||||
}
|
||||
@@ -408,8 +427,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
}
|
||||
|
||||
// No suggestions or blur, remove menu
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
if (!hasSuggestions(suggestions)) {
|
||||
menu.removeAttribute('style');
|
||||
return;
|
||||
}
|
||||
@@ -436,18 +454,12 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
|
||||
renderMenu = () => {
|
||||
const { portalPrefix } = this.props;
|
||||
const { suggestions } = this.state;
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
const { suggestions, typeaheadIndex } = this.state;
|
||||
if (!hasSuggestions(suggestions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard selectedIndex to be within the length of the suggestions
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
||||
const selectedItem: Suggestion | null =
|
||||
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
|
||||
const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
@@ -482,7 +494,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
|
||||
}
|
||||
}
|
||||
|
||||
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
|
||||
class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
|
||||
node: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
|
@@ -44,14 +44,14 @@ class QueryRow extends PureComponent<any, {}> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { edited, history, query, queryError, queryHint, request, supportsLogs } = this.props;
|
||||
const { history, query, queryError, queryHint, request, supportsLogs } = this.props;
|
||||
return (
|
||||
<div className="query-row">
|
||||
<div className="query-row-field">
|
||||
<QueryField
|
||||
error={queryError}
|
||||
hint={queryHint}
|
||||
initialQuery={edited ? null : query}
|
||||
initialQuery={query}
|
||||
history={history}
|
||||
portalPrefix="explore"
|
||||
onClickHintFix={this.onClickHintFix}
|
||||
@@ -79,7 +79,7 @@ class QueryRow extends PureComponent<any, {}> {
|
||||
|
||||
export default class QueryRows extends PureComponent<any, {}> {
|
||||
render() {
|
||||
const { className = '', queries, queryErrors = [], queryHints = [], ...handlers } = this.props;
|
||||
const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props;
|
||||
return (
|
||||
<div className={className}>
|
||||
{queries.map((q, index) => (
|
||||
@@ -89,7 +89,6 @@ export default class QueryRows extends PureComponent<any, {}> {
|
||||
query={q.query}
|
||||
queryError={queryErrors[index]}
|
||||
queryHint={queryHints[index]}
|
||||
edited={q.edited}
|
||||
{...handlers}
|
||||
/>
|
||||
))}
|
||||
|
@@ -5,7 +5,6 @@ import * as dateMath from 'app/core/utils/datemath';
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
|
@@ -23,7 +23,9 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.isSelected && !prevProps.isSelected) {
|
||||
scrollIntoView(this.el);
|
||||
requestAnimationFrame(() => {
|
||||
scrollIntoView(this.el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,31 +3,11 @@ import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ExploreUrlState } from 'app/types/explore';
|
||||
import { ExploreState } from 'app/types/explore';
|
||||
|
||||
import Explore, { ExploreState } from './Explore';
|
||||
import { DEFAULT_RANGE } from './TimePicker';
|
||||
|
||||
export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
if (initial) {
|
||||
try {
|
||||
return JSON.parse(decodeURI(initial));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
export function serializeStateToUrlParam(state: ExploreState): string {
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: state.datasourceName,
|
||||
queries: state.queries.map(q => ({ query: q.query })),
|
||||
range: state.range,
|
||||
};
|
||||
return JSON.stringify(urlState);
|
||||
}
|
||||
import Explore from './Explore';
|
||||
|
||||
interface WrapperProps {
|
||||
backendSrv?: any;
|
||||
|
@@ -1,14 +1,16 @@
|
||||
export function generateQueryKey(index = 0) {
|
||||
import { Query } from 'app/types/explore';
|
||||
|
||||
export function generateQueryKey(index = 0): string {
|
||||
return `Q-${Date.now()}-${Math.random()}-${index}`;
|
||||
}
|
||||
|
||||
export function ensureQueries(queries?) {
|
||||
export function ensureQueries(queries?: Query[]): Query[] {
|
||||
if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') {
|
||||
return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query }));
|
||||
}
|
||||
return [{ key: generateQueryKey(), query: '' }];
|
||||
}
|
||||
|
||||
export function hasQuery(queries) {
|
||||
return queries.some(q => q.query);
|
||||
export function hasQuery(queries: string[]): boolean {
|
||||
return queries.some(q => Boolean(q));
|
||||
}
|
||||
|
@@ -110,7 +110,7 @@ export function updateFolderPermission(itemToUpdate: DashboardAcl, level: Permis
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = toUpdateItem(itemToUpdate);
|
||||
const updated = toUpdateItem(item);
|
||||
|
||||
// if this is the item we want to update, update it's permisssion
|
||||
if (itemToUpdate === item) {
|
||||
|
@@ -1,6 +1,4 @@
|
||||
import './org_users_ctrl';
|
||||
import './profile_ctrl';
|
||||
import './org_users_ctrl';
|
||||
import './select_org_ctrl';
|
||||
import './change_password_ctrl';
|
||||
import './new_org_ctrl';
|
||||
|
@@ -1,87 +0,0 @@
|
||||
import config from 'app/core/config';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import Remarkable from 'remarkable';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class OrgUsersCtrl {
|
||||
unfiltered: any;
|
||||
users: any;
|
||||
pendingInvites: any;
|
||||
editor: any;
|
||||
navModel: any;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
externalUserMngInfo: string;
|
||||
canInvite: boolean;
|
||||
searchQuery: string;
|
||||
showInvites: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private backendSrv, navModelSrv, $sce) {
|
||||
this.navModel = navModelSrv.getNav('cfg', 'users', 0);
|
||||
|
||||
this.get();
|
||||
this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
|
||||
this.externalUserMngLinkName = config.externalUserMngLinkName;
|
||||
this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
|
||||
|
||||
// render external user management info markdown
|
||||
if (config.externalUserMngInfo) {
|
||||
this.externalUserMngInfo = new Remarkable({
|
||||
linkTarget: '__blank',
|
||||
}).render(config.externalUserMngInfo);
|
||||
}
|
||||
}
|
||||
|
||||
get() {
|
||||
this.backendSrv.get('/api/org/users').then(users => {
|
||||
this.users = users;
|
||||
this.unfiltered = users;
|
||||
});
|
||||
this.backendSrv.get('/api/org/invites').then(pendingInvites => {
|
||||
this.pendingInvites = pendingInvites;
|
||||
});
|
||||
}
|
||||
|
||||
onQueryUpdated() {
|
||||
const regex = new RegExp(this.searchQuery, 'ig');
|
||||
this.users = _.filter(this.unfiltered, item => {
|
||||
return regex.test(item.email) || regex.test(item.login);
|
||||
});
|
||||
}
|
||||
|
||||
updateOrgUser(user) {
|
||||
this.backendSrv.patch('/api/org/users/' + user.userId, user);
|
||||
}
|
||||
|
||||
removeUser(user) {
|
||||
this.$scope.appEvent('confirm-modal', {
|
||||
title: 'Delete',
|
||||
text: 'Are you sure you want to delete user ' + user.login + '?',
|
||||
yesText: 'Delete',
|
||||
icon: 'fa-warning',
|
||||
onConfirm: () => {
|
||||
this.removeUserConfirmed(user);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
removeUserConfirmed(user) {
|
||||
this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
|
||||
}
|
||||
|
||||
revokeInvite(invite, evt) {
|
||||
evt.stopPropagation();
|
||||
this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
|
||||
}
|
||||
|
||||
copyInviteToClipboard(evt) {
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
getInviteUrl(invite) {
|
||||
return invite.url;
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);
|
@@ -1,105 +0,0 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<div class="page-action-bar">
|
||||
<label class="gf-form gf-form--has-input-icon">
|
||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
|
||||
<i class="gf-form-input-icon fa fa-search"></i>
|
||||
</label>
|
||||
|
||||
<div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
|
||||
<button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
|
||||
Users
|
||||
</button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
|
||||
Pending Invites ({{ctrl.pendingInvites.length}})
|
||||
</button>
|
||||
<button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
|
||||
Users
|
||||
</button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
|
||||
Pending Invites ({{ctrl.pendingInvites.length}})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="page-action-bar__spacer"></div>
|
||||
|
||||
<a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
|
||||
<i class="fa fa-plus"></i>
|
||||
<span>Invite</span>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-success" ng-href="{{ctrl.externalUserMngLinkUrl}}" target="_blank" ng-if="ctrl.externalUserMngLinkUrl">
|
||||
<i class="fa fa-external-link-square"></i>
|
||||
{{ctrl.externalUserMngLinkName}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grafana-info-box" ng-if="ctrl.externalUserMngInfo">
|
||||
<span ng-bind-html="ctrl.externalUserMngInfo"></span>
|
||||
</div>
|
||||
|
||||
<div ng-hide="ctrl.showInvites">
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Login</th>
|
||||
<th>Email</th>
|
||||
<th>
|
||||
Seen
|
||||
<tip>Time since user was seen using Grafana</tip>
|
||||
</th>
|
||||
<th>Role</th>
|
||||
<th style="width: 34px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr ng-repeat="user in ctrl.users">
|
||||
<td class="width-4 text-center">
|
||||
<img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
|
||||
</td>
|
||||
<td>{{user.login}}</td>
|
||||
<td><span class="ellipsis">{{user.email}}</span></td>
|
||||
<td>{{user.lastSeenAtAge}}</td>
|
||||
<td>
|
||||
<div class="gf-form-select-wrapper width-12">
|
||||
<select type="text" ng-model="user.role" class="gf-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a ng-click="ctrl.removeUser(user)" class="btn btn-danger btn-mini">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.showInvites">
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th></th>
|
||||
<th style="width: 34px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr ng-repeat="invite in ctrl.pendingInvites">
|
||||
<td>{{invite.email}}</td>
|
||||
<td>{{invite.name}}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
|
||||
<i class="fa fa-clipboard"></i> Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-mini" ng-click="ctrl.revokeInvite(invite, $event)">
|
||||
<i class="fa fa-remove"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,7 +6,7 @@ import kbn from 'app/core/utils/kbn';
|
||||
import { PanelCtrl } from 'app/features/panel/panel_ctrl';
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import { renderUrl } from 'app/core/utils/url';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
|
||||
import { metricsTabDirective } from './metrics_tab';
|
||||
|
||||
@@ -314,7 +314,12 @@ class MetricsPanelCtrl extends PanelCtrl {
|
||||
|
||||
getAdditionalMenuItems() {
|
||||
const items = [];
|
||||
if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource && this.datasource.supportsExplore) {
|
||||
if (
|
||||
config.exploreEnabled &&
|
||||
this.contextSrv.isEditor &&
|
||||
this.datasource &&
|
||||
(this.datasource.meta.explore || this.datasource.meta.id === 'mixed')
|
||||
) {
|
||||
items.push({
|
||||
text: 'Explore',
|
||||
click: 'ctrl.explore();',
|
||||
@@ -325,14 +330,11 @@ class MetricsPanelCtrl extends PanelCtrl {
|
||||
return items;
|
||||
}
|
||||
|
||||
explore() {
|
||||
const range = this.timeSrv.timeRangeForUrl();
|
||||
const state = {
|
||||
...this.datasource.getExploreState(this.panel),
|
||||
range,
|
||||
};
|
||||
const exploreState = JSON.stringify(state);
|
||||
this.$location.url(renderUrl('/explore', { state: exploreState }));
|
||||
async explore() {
|
||||
const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
|
||||
if (url) {
|
||||
this.$timeout(() => this.$location.url(url));
|
||||
}
|
||||
}
|
||||
|
||||
addQuery(target) {
|
||||
|
@@ -38,7 +38,7 @@ describe('MetricsPanelCtrl', () => {
|
||||
describe('and has datasource set that supports explore and user has powers', () => {
|
||||
beforeEach(() => {
|
||||
ctrl.contextSrv = { isEditor: true };
|
||||
ctrl.datasource = { supportsExplore: true };
|
||||
ctrl.datasource = { meta: { explore: true } };
|
||||
additionalItems = ctrl.getAdditionalMenuItems();
|
||||
});
|
||||
|
||||
|
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { PluginActionBar, Props } from './PluginActionBar';
|
||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
searchQuery: '',
|
||||
layoutMode: LayoutModes.Grid,
|
||||
setLayoutMode: jest.fn(),
|
||||
setPluginsSearchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<PluginActionBar {...props} />);
|
||||
const instance = wrapper.instance() as PluginActionBar;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
@@ -1,62 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
|
||||
import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
searchQuery: string;
|
||||
layoutMode: LayoutMode;
|
||||
setLayoutMode: typeof setLayoutMode;
|
||||
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
||||
}
|
||||
|
||||
export class PluginActionBar extends PureComponent<Props> {
|
||||
onSearchQueryChange = event => {
|
||||
this.props.setPluginsSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { searchQuery, layoutMode, setLayoutMode } = this.props;
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-20"
|
||||
value={searchQuery}
|
||||
onChange={this.onSearchQueryChange}
|
||||
placeholder="Filter by name or type"
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
|
||||
</div>
|
||||
<div className="page-action-bar__spacer" />
|
||||
<a
|
||||
className="btn btn-success"
|
||||
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
|
||||
target="_blank"
|
||||
>
|
||||
Find more plugins on Grafana.com
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchQuery: getPluginsSearchQuery(state.plugins),
|
||||
layoutMode: getLayoutMode(state.plugins),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setPluginsSearchQuery,
|
||||
setLayoutMode,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);
|
@@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
navModel: {} as NavModel,
|
||||
plugins: [] as Plugin[],
|
||||
searchQuery: '',
|
||||
setPluginsSearchQuery: jest.fn(),
|
||||
setPluginsLayoutMode: jest.fn(),
|
||||
layoutMode: LayoutModes.Grid,
|
||||
loadPlugins: jest.fn(),
|
||||
};
|
||||
|
@@ -1,20 +1,23 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||
import PluginActionBar from './PluginActionBar';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
|
||||
import PluginList from './PluginList';
|
||||
import { NavModel, Plugin } from '../../types';
|
||||
import { loadPlugins } from './state/actions';
|
||||
import { NavModel, Plugin } from 'app/types';
|
||||
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import { getLayoutMode, getPlugins } from './state/selectors';
|
||||
import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
|
||||
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
plugins: Plugin[];
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
loadPlugins: typeof loadPlugins;
|
||||
setPluginsLayoutMode: typeof setPluginsLayoutMode;
|
||||
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
||||
}
|
||||
|
||||
export class PluginListPage extends PureComponent<Props> {
|
||||
@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, plugins, layoutMode } = this.props;
|
||||
const { 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} />
|
||||
<div className="page-container page-body">
|
||||
<PluginActionBar />
|
||||
<OrgActionBar
|
||||
searchQuery={searchQuery}
|
||||
layoutMode={layoutMode}
|
||||
onSetLayoutMode={mode => setPluginsLayoutMode(mode)}
|
||||
setSearchQuery={query => setPluginsSearchQuery(query)}
|
||||
linkButton={linkButton}
|
||||
/>
|
||||
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,11 +59,14 @@ function mapStateToProps(state) {
|
||||
navModel: getNavModel(state.navIndex, 'plugins'),
|
||||
plugins: getPlugins(state.plugins),
|
||||
layoutMode: getLayoutMode(state.plugins),
|
||||
searchQuery: getPluginsSearchQuery(state.plugins),
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadPlugins,
|
||||
setPluginsLayoutMode,
|
||||
setPluginsSearchQuery,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));
|
||||
|
@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(PluginActionBar) />
|
||||
<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]}
|
||||
/>
|
||||
<PluginList
|
||||
layoutMode="grid"
|
||||
plugins={Array []}
|
||||
|
@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
|
||||
payload: LayoutMode;
|
||||
}
|
||||
|
||||
export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
|
||||
export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
|
||||
type: ActionTypes.SetLayoutMode,
|
||||
payload: mode,
|
||||
});
|
||||
|
32
public/app/features/users/InviteesTable.test.tsx
Normal file
32
public/app/features/users/InviteesTable.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import InviteesTable, { Props } from './InviteesTable';
|
||||
import { Invitee } from 'app/types';
|
||||
import { getMockInvitees } from './__mocks__/userMocks';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
invitees: [] as Invitee[],
|
||||
onRevokeInvite: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<InviteesTable {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render invitees', () => {
|
||||
const wrapper = setup({
|
||||
invitees: getMockInvitees(5),
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
64
public/app/features/users/InviteesTable.tsx
Normal file
64
public/app/features/users/InviteesTable.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { createRef, PureComponent } from 'react';
|
||||
import { Invitee } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
invitees: Invitee[];
|
||||
onRevokeInvite: (code: string) => void;
|
||||
}
|
||||
|
||||
export default class InviteesTable extends PureComponent<Props> {
|
||||
private copyUrlRef = createRef<HTMLTextAreaElement>();
|
||||
|
||||
copyToClipboard = () => {
|
||||
const node = this.copyUrlRef.current;
|
||||
|
||||
if (node) {
|
||||
node.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { invitees, onRevokeInvite } = this.props;
|
||||
|
||||
return (
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th />
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitees.map((invitee, index) => {
|
||||
return (
|
||||
<tr key={`${invitee.id}-${index}`}>
|
||||
<td>{invitee.email}</td>
|
||||
<td>{invitee.name}</td>
|
||||
<td className="text-right">
|
||||
<button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
value={invitee.url}
|
||||
style={{ position: 'absolute', right: -1000 }}
|
||||
ref={this.copyUrlRef}
|
||||
/>
|
||||
<i className="fa fa-clipboard" /> Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-mini" onClick={() => onRevokeInvite(invitee.code)}>
|
||||
<i className="fa fa-remove" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
52
public/app/features/users/UsersActionBar.test.tsx
Normal file
52
public/app/features/users/UsersActionBar.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { UsersActionBar, Props } from './UsersActionBar';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
searchQuery: '',
|
||||
setUsersSearchQuery: jest.fn(),
|
||||
onShowInvites: jest.fn(),
|
||||
pendingInvitesCount: 0,
|
||||
canInvite: false,
|
||||
externalUserMngLinkUrl: '',
|
||||
externalUserMngLinkName: '',
|
||||
showInvites: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<UsersActionBar {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render pending invites button', () => {
|
||||
const wrapper = setup({
|
||||
pendingInvitesCount: 5,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show invite button', () => {
|
||||
const wrapper = setup({
|
||||
canInvite: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show external user management button', () => {
|
||||
const wrapper = setup({
|
||||
externalUserMngLinkUrl: 'some/url',
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
97
public/app/features/users/UsersActionBar.tsx
Normal file
97
public/app/features/users/UsersActionBar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames/bind';
|
||||
import { setUsersSearchQuery } from './state/actions';
|
||||
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
searchQuery: string;
|
||||
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||
onShowInvites: () => void;
|
||||
pendingInvitesCount: number;
|
||||
canInvite: boolean;
|
||||
showInvites: boolean;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
}
|
||||
|
||||
export class UsersActionBar extends PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
canInvite,
|
||||
externalUserMngLinkName,
|
||||
externalUserMngLinkUrl,
|
||||
searchQuery,
|
||||
pendingInvitesCount,
|
||||
setUsersSearchQuery,
|
||||
onShowInvites,
|
||||
showInvites,
|
||||
} = this.props;
|
||||
|
||||
const pendingInvitesButtonStyle = classNames({
|
||||
btn: true,
|
||||
'toggle-btn': true,
|
||||
active: showInvites,
|
||||
});
|
||||
|
||||
const usersButtonStyle = classNames({
|
||||
btn: true,
|
||||
'toggle-btn': true,
|
||||
active: !showInvites,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-20"
|
||||
value={searchQuery}
|
||||
onChange={event => setUsersSearchQuery(event.target.value)}
|
||||
placeholder="Filter by name or type"
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
{pendingInvitesCount > 0 && (
|
||||
<div style={{ marginLeft: '1rem' }}>
|
||||
<button className={usersButtonStyle} key="users" onClick={onShowInvites}>
|
||||
Users
|
||||
</button>
|
||||
<button className={pendingInvitesButtonStyle} onClick={onShowInvites} key="pending-invites">
|
||||
Pending Invites ({pendingInvitesCount})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-action-bar__spacer" />
|
||||
{canInvite && (
|
||||
<a className="btn btn-success" href="org/users/invite">
|
||||
<span>Invite</span>
|
||||
</a>
|
||||
)}
|
||||
{externalUserMngLinkUrl && (
|
||||
<a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
|
||||
<i className="fa fa-external-link-square" /> {externalUserMngLinkName}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchQuery: getUsersSearchQuery(state.users),
|
||||
pendingInvitesCount: getInviteesCount(state.users),
|
||||
externalUserMngLinkName: state.users.externalUserMngLinkName,
|
||||
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
|
||||
canInvite: state.users.canInvite,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setUsersSearchQuery,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);
|
55
public/app/features/users/UsersListPage.test.tsx
Normal file
55
public/app/features/users/UsersListPage.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { UsersListPage, Props } from './UsersListPage';
|
||||
import { Invitee, NavModel, OrgUser } from 'app/types';
|
||||
import { getMockUser } from './__mocks__/userMocks';
|
||||
import appEvents from '../../core/app_events';
|
||||
|
||||
jest.mock('../../core/app_events', () => ({
|
||||
emit: jest.fn(),
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
navModel: {} as NavModel,
|
||||
users: [] as OrgUser[],
|
||||
invitees: [] as Invitee[],
|
||||
searchQuery: '',
|
||||
externalUserMngInfo: '',
|
||||
revokeInvite: jest.fn(),
|
||||
loadInvitees: jest.fn(),
|
||||
loadUsers: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
removeUser: jest.fn(),
|
||||
setUsersSearchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<UsersListPage {...props} />);
|
||||
const instance = wrapper.instance() as UsersListPage;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
it('should emit show remove user modal', () => {
|
||||
const { instance } = setup();
|
||||
const mockUser = getMockUser();
|
||||
|
||||
instance.onRemoveUser(mockUser);
|
||||
|
||||
expect(appEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
136
public/app/features/users/UsersListPage.tsx
Normal file
136
public/app/features/users/UsersListPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import Remarkable from 'remarkable';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import UsersActionBar from './UsersActionBar';
|
||||
import UsersTable from 'app/features/users/UsersTable';
|
||||
import InviteesTable from './InviteesTable';
|
||||
import { Invitee, NavModel, OrgUser } from 'app/types';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
invitees: Invitee[];
|
||||
users: OrgUser[];
|
||||
searchQuery: string;
|
||||
externalUserMngInfo: string;
|
||||
loadUsers: typeof loadUsers;
|
||||
loadInvitees: typeof loadInvitees;
|
||||
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||
updateUser: typeof updateUser;
|
||||
removeUser: typeof removeUser;
|
||||
revokeInvite: typeof revokeInvite;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
showInvites: boolean;
|
||||
}
|
||||
|
||||
export class UsersListPage extends PureComponent<Props, State> {
|
||||
externalUserMngInfoHtml: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (this.props.externalUserMngInfo) {
|
||||
const markdownRenderer = new Remarkable();
|
||||
this.externalUserMngInfoHtml = markdownRenderer.render(this.props.externalUserMngInfo);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
showInvites: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchUsers();
|
||||
this.fetchInvitees();
|
||||
}
|
||||
|
||||
async fetchUsers() {
|
||||
return await this.props.loadUsers();
|
||||
}
|
||||
|
||||
async fetchInvitees() {
|
||||
return await this.props.loadInvitees();
|
||||
}
|
||||
|
||||
onRoleChange = (role, user) => {
|
||||
const updatedUser = { ...user, role: role };
|
||||
|
||||
this.props.updateUser(updatedUser);
|
||||
};
|
||||
|
||||
onRemoveUser = user => {
|
||||
appEvents.emit('confirm-modal', {
|
||||
title: 'Delete',
|
||||
text: 'Are you sure you want to delete user ' + user.login + '?',
|
||||
yesText: 'Delete',
|
||||
icon: 'fa-warning',
|
||||
onConfirm: () => {
|
||||
this.props.removeUser(user.userId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onRevokeInvite = code => {
|
||||
this.props.revokeInvite(code);
|
||||
};
|
||||
|
||||
onShowInvites = () => {
|
||||
this.setState(prevState => ({
|
||||
showInvites: !prevState.showInvites,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { invitees, navModel, users } = this.props;
|
||||
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
<UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, 'users'),
|
||||
users: getUsers(state.users),
|
||||
searchQuery: getUsersSearchQuery(state.users),
|
||||
invitees: getInvitees(state.users),
|
||||
externalUserMngInfo: state.users.externalUserMngInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadUsers,
|
||||
loadInvitees,
|
||||
setUsersSearchQuery,
|
||||
updateUser,
|
||||
removeUser,
|
||||
revokeInvite,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));
|
33
public/app/features/users/UsersTable.test.tsx
Normal file
33
public/app/features/users/UsersTable.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import UsersTable, { Props } from './UsersTable';
|
||||
import { OrgUser } from 'app/types';
|
||||
import { getMockUsers } from './__mocks__/userMocks';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
users: [] as OrgUser[],
|
||||
onRoleChange: jest.fn(),
|
||||
onRemoveUser: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<UsersTable {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render users table', () => {
|
||||
const wrapper = setup({
|
||||
users: getMockUsers(5),
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
67
public/app/features/users/UsersTable.tsx
Normal file
67
public/app/features/users/UsersTable.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { SFC } from 'react';
|
||||
import { OrgUser } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
users: OrgUser[];
|
||||
onRoleChange: (role: string, user: OrgUser) => void;
|
||||
onRemoveUser: (user: OrgUser) => void;
|
||||
}
|
||||
|
||||
const UsersTable: SFC<Props> = props => {
|
||||
const { users, onRoleChange, onRemoveUser } = props;
|
||||
|
||||
return (
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Login</th>
|
||||
<th>Email</th>
|
||||
<th>Seen</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, index) => {
|
||||
return (
|
||||
<tr key={`${user.userId}-${index}`}>
|
||||
<td className="width-4 text-center">
|
||||
<img className="filter-table__avatar" src={user.avatarUrl} />
|
||||
</td>
|
||||
<td>{user.login}</td>
|
||||
<td>
|
||||
<span className="ellipsis">{user.email}</span>
|
||||
</td>
|
||||
<td>{user.lastSeenAtAge}</td>
|
||||
<td>
|
||||
<div className="gf-form-select-wrapper width-12">
|
||||
<select
|
||||
value={user.role}
|
||||
className="gf-form-input"
|
||||
onChange={event => onRoleChange(event.target.value, user)}
|
||||
>
|
||||
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
|
||||
return (
|
||||
<option value={option} key={`${option}-${index}`}>
|
||||
{option}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTable;
|
56
public/app/features/users/__mocks__/userMocks.ts
Normal file
56
public/app/features/users/__mocks__/userMocks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const getMockUsers = (amount: number) => {
|
||||
const users = [];
|
||||
|
||||
for (let i = 0; i <= amount; i++) {
|
||||
users.push({
|
||||
avatarUrl: 'url/to/avatar',
|
||||
email: `user-${i}@test.com`,
|
||||
lastSeenAt: '2018-10-01',
|
||||
lastSeenAtAge: '',
|
||||
login: `user-${i}`,
|
||||
orgId: 1,
|
||||
role: 'Admin',
|
||||
userId: i,
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
};
|
||||
|
||||
export const getMockUser = () => {
|
||||
return {
|
||||
avatarUrl: 'url/to/avatar',
|
||||
email: `user@test.com`,
|
||||
lastSeenAt: '2018-10-01',
|
||||
lastSeenAtAge: '',
|
||||
login: `user`,
|
||||
orgId: 1,
|
||||
role: 'Admin',
|
||||
userId: 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMockInvitees = (amount: number) => {
|
||||
const invitees = [];
|
||||
|
||||
for (let i = 0; i <= amount; i++) {
|
||||
invitees.push({
|
||||
code: `asdfasdfsadf-${i}`,
|
||||
createdOn: '2018-10-02',
|
||||
email: `invitee-${i}@test.com`,
|
||||
emailSent: true,
|
||||
emailSentOn: '2018-10-02',
|
||||
id: i,
|
||||
invitedByEmail: 'admin@grafana.com',
|
||||
invitedByLogin: 'admin',
|
||||
invitedByName: 'admin',
|
||||
name: `invitee-${i}`,
|
||||
orgId: 1,
|
||||
role: 'viewer',
|
||||
status: 'not accepted',
|
||||
url: `localhost/invite/$${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
return invitees;
|
||||
};
|
@@ -0,0 +1,318 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<table
|
||||
className="filter-table form-inline"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th />
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody />
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`Render should render invitees 1`] = `
|
||||
<table
|
||||
className="filter-table form-inline"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th />
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
key="0-0"
|
||||
>
|
||||
<td>
|
||||
invitee-0@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-0
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$0"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="1-1"
|
||||
>
|
||||
<td>
|
||||
invitee-1@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-1
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$1"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="2-2"
|
||||
>
|
||||
<td>
|
||||
invitee-2@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-2
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$2"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="3-3"
|
||||
>
|
||||
<td>
|
||||
invitee-3@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-3
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$3"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="4-4"
|
||||
>
|
||||
<td>
|
||||
invitee-4@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-4
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$4"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="5-5"
|
||||
>
|
||||
<td>
|
||||
invitee-5@test.com
|
||||
</td>
|
||||
<td>
|
||||
invitee-5
|
||||
</td>
|
||||
<td
|
||||
className="text-right"
|
||||
>
|
||||
<button
|
||||
className="btn btn-inverse btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
readOnly={true}
|
||||
style={
|
||||
Object {
|
||||
"position": "absolute",
|
||||
"right": -1000,
|
||||
}
|
||||
}
|
||||
value="localhost/invite/$5"
|
||||
/>
|
||||
<i
|
||||
className="fa fa-clipboard"
|
||||
/>
|
||||
Copy Invite
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
@@ -0,0 +1,155 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input width-20"
|
||||
onChange={[Function]}
|
||||
placeholder="Filter by name or type"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render pending invites button 1`] = `
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input width-20"
|
||||
onChange={[Function]}
|
||||
placeholder="Filter by name or type"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"marginLeft": "1rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn toggle-btn active"
|
||||
key="users"
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
className="btn toggle-btn"
|
||||
key="pending-invites"
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
Pending Invites (
|
||||
5
|
||||
)
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should show external user management button 1`] = `
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input width-20"
|
||||
onChange={[Function]}
|
||||
placeholder="Filter by name or type"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<a
|
||||
className="btn btn-success"
|
||||
href="some/url"
|
||||
target="_blank"
|
||||
>
|
||||
<i
|
||||
className="fa fa-external-link-square"
|
||||
/>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should show invite button 1`] = `
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input width-20"
|
||||
onChange={[Function]}
|
||||
placeholder="Filter by name or type"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<a
|
||||
className="btn btn-success"
|
||||
href="org/users/invite"
|
||||
>
|
||||
<span>
|
||||
Invite
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@@ -0,0 +1,22 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(UsersActionBar)
|
||||
onShowInvites={[Function]}
|
||||
showInvites={false}
|
||||
/>
|
||||
<UsersTable
|
||||
onRemoveUser={[Function]}
|
||||
onRoleChange={[Function]}
|
||||
users={Array []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
444
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
Normal file
444
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
Normal file
@@ -0,0 +1,444 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<table
|
||||
className="filter-table form-inline"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>
|
||||
Login
|
||||
</th>
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th>
|
||||
Seen
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody />
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`Render should render users table 1`] = `
|
||||
<table
|
||||
className="filter-table form-inline"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>
|
||||
Login
|
||||
</th>
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th>
|
||||
Seen
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
key="0-0"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-0
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-0@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="1-1"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-1
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-1@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="2-2"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-2
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-2@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="3-3"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-3
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-3@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="4-4"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-4
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-4@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="5-5"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
user-5
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="ellipsis"
|
||||
>
|
||||
user-5@test.com
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<div
|
||||
className="gf-form-select-wrapper width-12"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
value="Admin"
|
||||
>
|
||||
<option
|
||||
key="Viewer-0"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor-1"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin-2"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
79
public/app/features/users/state/actions.ts
Normal file
79
public/app/features/users/state/actions.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
import { StoreState } from '../../../types';
|
||||
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||
import { Invitee, OrgUser } from 'app/types';
|
||||
|
||||
export enum ActionTypes {
|
||||
LoadUsers = 'LOAD_USERS',
|
||||
LoadInvitees = 'LOAD_INVITEES',
|
||||
SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
|
||||
}
|
||||
|
||||
export interface LoadUsersAction {
|
||||
type: ActionTypes.LoadUsers;
|
||||
payload: OrgUser[];
|
||||
}
|
||||
|
||||
export interface LoadInviteesAction {
|
||||
type: ActionTypes.LoadInvitees;
|
||||
payload: Invitee[];
|
||||
}
|
||||
|
||||
export interface SetUsersSearchQueryAction {
|
||||
type: ActionTypes.SetUsersSearchQuery;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
|
||||
type: ActionTypes.LoadUsers,
|
||||
payload: users,
|
||||
});
|
||||
|
||||
const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
|
||||
type: ActionTypes.LoadInvitees,
|
||||
payload: invitees,
|
||||
});
|
||||
|
||||
export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
|
||||
type: ActionTypes.SetUsersSearchQuery,
|
||||
payload: query,
|
||||
});
|
||||
|
||||
export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
|
||||
|
||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||
|
||||
export function loadUsers(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const users = await getBackendSrv().get('/api/org/users');
|
||||
dispatch(usersLoaded(users));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadInvitees(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const invitees = await getBackendSrv().get('/api/org/invites');
|
||||
dispatch(inviteesLoaded(invitees));
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUser(user: OrgUser): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
|
||||
dispatch(loadUsers());
|
||||
};
|
||||
}
|
||||
|
||||
export function removeUser(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().delete(`/api/org/users/${userId}`);
|
||||
dispatch(loadUsers());
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeInvite(code: string): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
|
||||
dispatch(loadInvitees());
|
||||
};
|
||||
}
|
32
public/app/features/users/state/reducers.ts
Normal file
32
public/app/features/users/state/reducers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Invitee, OrgUser, UsersState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
import config from '../../../core/config';
|
||||
|
||||
export const initialState: UsersState = {
|
||||
invitees: [] as Invitee[],
|
||||
users: [] as OrgUser[],
|
||||
searchQuery: '',
|
||||
canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
|
||||
externalUserMngInfo: config.externalUserMngInfo,
|
||||
externalUserMngLinkName: config.externalUserMngLinkName,
|
||||
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
|
||||
};
|
||||
|
||||
export const usersReducer = (state = initialState, action: Action): UsersState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadUsers:
|
||||
return { ...state, users: action.payload };
|
||||
|
||||
case ActionTypes.LoadInvitees:
|
||||
return { ...state, invitees: action.payload };
|
||||
|
||||
case ActionTypes.SetUsersSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default {
|
||||
users: usersReducer,
|
||||
};
|
18
public/app/features/users/state/selectors.ts
Normal file
18
public/app/features/users/state/selectors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const getUsers = state => {
|
||||
const regex = new RegExp(state.searchQuery, 'i');
|
||||
|
||||
return state.users.filter(user => {
|
||||
return regex.test(user.login) || regex.test(user.email);
|
||||
});
|
||||
};
|
||||
|
||||
export const getInvitees = state => {
|
||||
const regex = new RegExp(state.searchQuery, 'i');
|
||||
|
||||
return state.invitees.filter(invitee => {
|
||||
return regex.test(invitee.name) || regex.test(invitee.email);
|
||||
});
|
||||
};
|
||||
|
||||
export const getInviteesCount = state => state.invitees.length;
|
||||
export const getUsersSearchQuery = state => state.searchQuery;
|
@@ -8,7 +8,6 @@ import * as templatingVariable from 'app/features/templating/variable';
|
||||
export default class CloudWatchDatasource {
|
||||
type: any;
|
||||
name: any;
|
||||
supportMetrics: any;
|
||||
proxyUrl: any;
|
||||
defaultRegion: any;
|
||||
instanceSettings: any;
|
||||
@@ -17,7 +16,6 @@ export default class CloudWatchDatasource {
|
||||
constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
|
||||
this.type = 'cloudwatch';
|
||||
this.name = instanceSettings.name;
|
||||
this.supportMetrics = true;
|
||||
this.proxyUrl = instanceSettings.url;
|
||||
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
|
||||
this.instanceSettings = instanceSettings;
|
||||
|
@@ -16,8 +16,6 @@ export default class InfluxDatasource {
|
||||
basicAuth: any;
|
||||
withCredentials: any;
|
||||
interval: any;
|
||||
supportAnnotations: boolean;
|
||||
supportMetrics: boolean;
|
||||
responseParser: any;
|
||||
|
||||
/** @ngInject */
|
||||
@@ -34,8 +32,6 @@ export default class InfluxDatasource {
|
||||
this.basicAuth = instanceSettings.basicAuth;
|
||||
this.withCredentials = instanceSettings.withCredentials;
|
||||
this.interval = (instanceSettings.jsonData || {}).timeInterval;
|
||||
this.supportAnnotations = true;
|
||||
this.supportMetrics = true;
|
||||
this.responseParser = new ResponseParser();
|
||||
}
|
||||
|
||||
|
@@ -10,7 +10,6 @@ export default class OpenTsDatasource {
|
||||
basicAuth: any;
|
||||
tsdbVersion: any;
|
||||
tsdbResolution: any;
|
||||
supportMetrics: any;
|
||||
tagKeys: any;
|
||||
|
||||
aggregatorsPromise: any;
|
||||
@@ -26,7 +25,6 @@ export default class OpenTsDatasource {
|
||||
instanceSettings.jsonData = instanceSettings.jsonData || {};
|
||||
this.tsdbVersion = instanceSettings.jsonData.tsdbVersion || 1;
|
||||
this.tsdbResolution = instanceSettings.jsonData.tsdbResolution || 1;
|
||||
this.supportMetrics = true;
|
||||
this.tagKeys = {};
|
||||
|
||||
this.aggregatorsPromise = null;
|
||||
|
@@ -8,6 +8,7 @@ import { ResultTransformer } from './result_transformer';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import addLabelToQuery from './add_label_to_query';
|
||||
import { getQueryHints } from './query_hints';
|
||||
|
||||
export function alignRange(start, end, step) {
|
||||
const alignedEnd = Math.ceil(end / step) * step;
|
||||
@@ -18,104 +19,6 @@ export function alignRange(start, end, step) {
|
||||
};
|
||||
}
|
||||
|
||||
export function determineQueryHints(series: any[], datasource?: any): any[] {
|
||||
const hints = series.map((s, i) => {
|
||||
const query: string = s.query;
|
||||
const index: number = s.responseIndex;
|
||||
if (query === undefined || index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ..._bucket metric needs a histogram_quantile()
|
||||
const histogramMetric = query.trim().match(/^\w+_bucket$/);
|
||||
if (histogramMetric) {
|
||||
const label = 'Time series has buckets, you probably wanted a histogram.';
|
||||
return {
|
||||
index,
|
||||
label,
|
||||
fix: {
|
||||
label: 'Fix by adding histogram_quantile().',
|
||||
action: {
|
||||
type: 'ADD_HISTOGRAM_QUANTILE',
|
||||
query,
|
||||
index,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check for monotony
|
||||
const datapoints: number[][] = s.datapoints;
|
||||
if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
|
||||
let increasing = false;
|
||||
const monotonic = datapoints.filter(dp => dp[0] !== null).every((dp, index) => {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
increasing = increasing || dp[0] > datapoints[index - 1][0];
|
||||
// monotonic?
|
||||
return dp[0] >= datapoints[index - 1][0];
|
||||
});
|
||||
if (increasing && monotonic) {
|
||||
const simpleMetric = query.trim().match(/^\w+$/);
|
||||
let label = 'Time series is monotonously increasing.';
|
||||
let fix;
|
||||
if (simpleMetric) {
|
||||
fix = {
|
||||
label: 'Fix by adding rate().',
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query,
|
||||
index,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
label = `${label} Try applying a rate() function.`;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
index,
|
||||
fix,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for recording rules expansion
|
||||
if (datasource && datasource.ruleMappings) {
|
||||
const mapping = datasource.ruleMappings;
|
||||
const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
|
||||
if (query.search(ruleName) > -1) {
|
||||
return {
|
||||
...acc,
|
||||
[ruleName]: mapping[ruleName],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
if (_.size(mappingForQuery) > 0) {
|
||||
const label = 'Query contains recording rules.';
|
||||
return {
|
||||
label,
|
||||
index,
|
||||
fix: {
|
||||
label: 'Expand rules',
|
||||
action: {
|
||||
type: 'EXPAND_RULES',
|
||||
query,
|
||||
index,
|
||||
mapping: mappingForQuery,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No hint found
|
||||
return null;
|
||||
});
|
||||
return hints;
|
||||
}
|
||||
|
||||
export function extractRuleMappingFromGroups(groups: any[]) {
|
||||
return groups.reduce(
|
||||
(mapping, group) =>
|
||||
@@ -149,8 +52,6 @@ export class PrometheusDatasource {
|
||||
editorSrc: string;
|
||||
name: string;
|
||||
ruleMappings: { [index: string]: string };
|
||||
supportsExplore: boolean;
|
||||
supportMetrics: boolean;
|
||||
url: string;
|
||||
directUrl: string;
|
||||
basicAuth: any;
|
||||
@@ -166,8 +67,6 @@ export class PrometheusDatasource {
|
||||
this.type = 'prometheus';
|
||||
this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
|
||||
this.name = instanceSettings.name;
|
||||
this.supportsExplore = true;
|
||||
this.supportMetrics = true;
|
||||
this.url = instanceSettings.url;
|
||||
this.directUrl = instanceSettings.directUrl;
|
||||
this.basicAuth = instanceSettings.basicAuth;
|
||||
@@ -304,7 +203,7 @@ export class PrometheusDatasource {
|
||||
result = [...result, ...series];
|
||||
|
||||
if (queries[index].hinting) {
|
||||
const queryHints = determineQueryHints(series, this);
|
||||
const queryHints = getQueryHints(series, this);
|
||||
hints = [...hints, ...queryHints];
|
||||
}
|
||||
});
|
||||
@@ -522,10 +421,10 @@ export class PrometheusDatasource {
|
||||
});
|
||||
}
|
||||
|
||||
getExploreState(panel) {
|
||||
getExploreState(targets: any[]) {
|
||||
let state = {};
|
||||
if (panel.targets) {
|
||||
const queries = panel.targets.map(t => ({
|
||||
if (targets && targets.length > 0) {
|
||||
const queries = targets.map(t => ({
|
||||
query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr),
|
||||
format: t.format,
|
||||
}));
|
||||
|
100
public/app/plugins/datasource/prometheus/query_hints.ts
Normal file
100
public/app/plugins/datasource/prometheus/query_hints.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export function getQueryHints(series: any[], datasource?: any): any[] {
|
||||
const hints = series.map((s, i) => {
|
||||
const query: string = s.query;
|
||||
const index: number = s.responseIndex;
|
||||
if (query === undefined || index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ..._bucket metric needs a histogram_quantile()
|
||||
const histogramMetric = query.trim().match(/^\w+_bucket$/);
|
||||
if (histogramMetric) {
|
||||
const label = 'Time series has buckets, you probably wanted a histogram.';
|
||||
return {
|
||||
index,
|
||||
label,
|
||||
fix: {
|
||||
label: 'Fix by adding histogram_quantile().',
|
||||
action: {
|
||||
type: 'ADD_HISTOGRAM_QUANTILE',
|
||||
query,
|
||||
index,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check for monotony
|
||||
const datapoints: number[][] = s.datapoints;
|
||||
if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
|
||||
let increasing = false;
|
||||
const nonNullData = datapoints.filter(dp => dp[0] !== null);
|
||||
const monotonic = nonNullData.every((dp, index) => {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
increasing = increasing || dp[0] > nonNullData[index - 1][0];
|
||||
// monotonic?
|
||||
return dp[0] >= nonNullData[index - 1][0];
|
||||
});
|
||||
if (increasing && monotonic) {
|
||||
const simpleMetric = query.trim().match(/^\w+$/);
|
||||
let label = 'Time series is monotonously increasing.';
|
||||
let fix;
|
||||
if (simpleMetric) {
|
||||
fix = {
|
||||
label: 'Fix by adding rate().',
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query,
|
||||
index,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
label = `${label} Try applying a rate() function.`;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
index,
|
||||
fix,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for recording rules expansion
|
||||
if (datasource && datasource.ruleMappings) {
|
||||
const mapping = datasource.ruleMappings;
|
||||
const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
|
||||
if (query.search(ruleName) > -1) {
|
||||
return {
|
||||
...acc,
|
||||
[ruleName]: mapping[ruleName],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
if (_.size(mappingForQuery) > 0) {
|
||||
const label = 'Query contains recording rules.';
|
||||
return {
|
||||
label,
|
||||
index,
|
||||
fix: {
|
||||
label: 'Expand rules',
|
||||
action: {
|
||||
type: 'EXPAND_RULES',
|
||||
query,
|
||||
index,
|
||||
mapping: mappingForQuery,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No hint found
|
||||
return null;
|
||||
});
|
||||
return hints;
|
||||
}
|
@@ -3,7 +3,6 @@ import moment from 'moment';
|
||||
import q from 'q';
|
||||
import {
|
||||
alignRange,
|
||||
determineQueryHints,
|
||||
extractRuleMappingFromGroups,
|
||||
PrometheusDatasource,
|
||||
prometheusSpecialRegexEscape,
|
||||
@@ -216,84 +215,6 @@ describe('PrometheusDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineQueryHints()', () => {
|
||||
it('returns no hints for no series', () => {
|
||||
expect(determineQueryHints([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no hints for empty series', () => {
|
||||
expect(determineQueryHints([{ datapoints: [], query: '' }])).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns no hint for a monotonously decreasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns a rate hint for a monotonously increasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series is monotonously increasing.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query: 'metric',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no rate hint for a monotonously increasing series that already has a rate', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0].label).toContain('rate()');
|
||||
expect(hints[0].fix).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a rate hint for a monotonously increasing series with missing data', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series is monotonously increasing.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query: 'metric',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a histogram hint for a bucket series', () => {
|
||||
const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
|
||||
const hints = determineQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series has buckets, you probably wanted a histogram.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_HISTOGRAM_QUANTILE',
|
||||
query: 'metric_bucket',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRuleMappingFromGroups()', () => {
|
||||
it('returns empty mapping for no rule groups', () => {
|
||||
expect(extractRuleMappingFromGroups([])).toEqual({});
|
||||
|
@@ -0,0 +1,87 @@
|
||||
import { getQueryHints } from '../query_hints';
|
||||
|
||||
describe('getQueryHints()', () => {
|
||||
it('returns no hints for no series', () => {
|
||||
expect(getQueryHints([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no hints for empty series', () => {
|
||||
expect(getQueryHints([{ datapoints: [], query: '' }])).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns no hint for a monotonously decreasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns no hint for a flat series', () => {
|
||||
const series = [
|
||||
{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]], query: 'metric', responseIndex: 0 },
|
||||
];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns a rate hint for a monotonously increasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series is monotonously increasing.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query: 'metric',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no rate hint for a monotonously increasing series that already has a rate', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0].label).toContain('rate()');
|
||||
expect(hints[0].fix).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a rate hint for a monotonously increasing series with missing data', () => {
|
||||
const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series is monotonously increasing.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_RATE',
|
||||
query: 'metric',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a histogram hint for a bucket series', () => {
|
||||
const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
|
||||
const hints = getQueryHints(series);
|
||||
expect(hints.length).toBe(1);
|
||||
expect(hints[0]).toMatchObject({
|
||||
label: 'Time series has buckets, you probably wanted a histogram.',
|
||||
index: 0,
|
||||
fix: {
|
||||
action: {
|
||||
type: 'ADD_HISTOGRAM_QUANTILE',
|
||||
query: 'metric_bucket',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@@ -24,6 +24,7 @@ export class StackdriverAggregationCtrl {
|
||||
alignOptions: any[];
|
||||
target: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope) {
|
||||
this.$scope.ctrl = this;
|
||||
this.target = $scope.target;
|
||||
|
@@ -4,6 +4,7 @@ import { FilterSegments, DefaultRemoveFilterValue } from './filter_segments';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export class StackdriverFilter {
|
||||
/** @ngInject */
|
||||
constructor() {
|
||||
return {
|
||||
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import TableModel from 'app/core/table_model';
|
||||
|
||||
class TestDataDatasource {
|
||||
id: any;
|
||||
@@ -29,16 +30,29 @@ class TestDataDatasource {
|
||||
}
|
||||
|
||||
return this.backendSrv
|
||||
.post('/api/tsdb/query', {
|
||||
from: options.range.from.valueOf().toString(),
|
||||
to: options.range.to.valueOf().toString(),
|
||||
queries: queries,
|
||||
.datasourceRequest({
|
||||
method: 'POST',
|
||||
url: '/api/tsdb/query',
|
||||
data: {
|
||||
from: options.range.from.valueOf().toString(),
|
||||
to: options.range.to.valueOf().toString(),
|
||||
queries: queries,
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
const data = [];
|
||||
|
||||
if (res.results) {
|
||||
_.forEach(res.results, queryRes => {
|
||||
if (res.data.results) {
|
||||
_.forEach(res.data.results, queryRes => {
|
||||
if (queryRes.tables) {
|
||||
for (const table of queryRes.tables) {
|
||||
const model = new TableModel();
|
||||
model.rows = table.rows;
|
||||
model.columns = table.columns;
|
||||
|
||||
data.push(model);
|
||||
}
|
||||
}
|
||||
for (const series of queryRes.series) {
|
||||
data.push({
|
||||
target: series.name,
|
||||
@@ -48,17 +62,28 @@ class TestDataDatasource {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(res);
|
||||
return { data: data };
|
||||
});
|
||||
}
|
||||
|
||||
annotationQuery(options) {
|
||||
return this.backendSrv.get('/api/annotations', {
|
||||
from: options.range.from.valueOf(),
|
||||
to: options.range.to.valueOf(),
|
||||
limit: options.limit,
|
||||
type: options.type,
|
||||
});
|
||||
let timeWalker = options.range.from.valueOf();
|
||||
const to = options.range.to.valueOf();
|
||||
const events = [];
|
||||
const eventCount = 10;
|
||||
const step = (to - timeWalker) / eventCount;
|
||||
|
||||
for (let i = 0; i < eventCount; i++) {
|
||||
events.push({
|
||||
annotation: options.annotation,
|
||||
time: timeWalker,
|
||||
text: 'This is the text, <a href="https://grafana.com">Grafana.com</a>',
|
||||
tags: ['text', 'server'],
|
||||
});
|
||||
timeWalker += step;
|
||||
}
|
||||
return this.$q.when(events);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -6,7 +6,7 @@ class TestDataAnnotationsQueryCtrl {
|
||||
|
||||
constructor() {}
|
||||
|
||||
static template = '<h2>test data</h2>';
|
||||
static template = '<h2>Annotation scenario</h2>';
|
||||
}
|
||||
|
||||
export {
|
||||
|
@@ -11,6 +11,7 @@ import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
|
||||
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';
|
||||
|
||||
/** @ngInject */
|
||||
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
@@ -135,9 +136,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controller: 'NewOrgCtrl',
|
||||
})
|
||||
.when('/org/users', {
|
||||
templateUrl: 'public/app/features/org/partials/orgUsers.html',
|
||||
controller: 'OrgUsersCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => UsersListPage,
|
||||
},
|
||||
})
|
||||
.when('/org/users/invite', {
|
||||
templateUrl: 'public/app/features/org/partials/invite.html',
|
||||
|
@@ -9,6 +9,7 @@ import foldersReducers from 'app/features/folders/state/reducers';
|
||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||
import pluginReducers from 'app/features/plugins/state/reducers';
|
||||
import dataSourcesReducers from 'app/features/datasources/state/reducers';
|
||||
import usersReducers from 'app/features/users/state/reducers';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
...sharedReducers,
|
||||
@@ -19,6 +20,7 @@ const rootReducer = combineReducers({
|
||||
...dashboardReducers,
|
||||
...pluginReducers,
|
||||
...dataSourcesReducers,
|
||||
...usersReducers,
|
||||
});
|
||||
|
||||
export let store;
|
||||
|
@@ -1,3 +1,8 @@
|
||||
interface ExploreDatasource {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
from: string;
|
||||
to: string;
|
||||
@@ -5,10 +10,46 @@ export interface Range {
|
||||
|
||||
export interface Query {
|
||||
query: string;
|
||||
edited?: boolean;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface ExploreState {
|
||||
datasource: any;
|
||||
datasourceError: any;
|
||||
datasourceLoading: boolean | null;
|
||||
datasourceMissing: boolean;
|
||||
datasourceName?: string;
|
||||
exploreDatasources: ExploreDatasource[];
|
||||
graphResult: any;
|
||||
history: any[];
|
||||
latency: number;
|
||||
loading: any;
|
||||
logsResult: any;
|
||||
/**
|
||||
* Initial rows of queries to push down the tree.
|
||||
* Modifications do not end up here, but in `this.queryExpressions`.
|
||||
* The only way to reset a query is to change its `key`.
|
||||
*/
|
||||
queries: Query[];
|
||||
/**
|
||||
* Errors caused by the running the query row.
|
||||
*/
|
||||
queryErrors: any[];
|
||||
/**
|
||||
* Hints gathered for the query row.
|
||||
*/
|
||||
queryHints: any[];
|
||||
range: Range;
|
||||
requestOptions: any;
|
||||
showingGraph: boolean;
|
||||
showingLogs: boolean;
|
||||
showingTable: boolean;
|
||||
supportsGraph: boolean | null;
|
||||
supportsLogs: boolean | null;
|
||||
supportsTable: boolean | null;
|
||||
tableResult: any;
|
||||
}
|
||||
|
||||
export interface ExploreUrlState {
|
||||
datasource: string;
|
||||
queries: Query[];
|
||||
|
@@ -6,7 +6,7 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
|
||||
import { DashboardState } from './dashboard';
|
||||
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
|
||||
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
|
||||
import { User } from './user';
|
||||
import { Invitee, OrgUser, User, UsersState } from './user';
|
||||
import { DataSource, DataSourcesState } from './datasources';
|
||||
import { PluginMeta, Plugin, PluginsState } from './plugins';
|
||||
|
||||
@@ -38,10 +38,13 @@ export {
|
||||
ApiKey,
|
||||
ApiKeysState,
|
||||
NewApiKey,
|
||||
User,
|
||||
Plugin,
|
||||
PluginsState,
|
||||
DataSourcesState,
|
||||
Invitee,
|
||||
OrgUser,
|
||||
User,
|
||||
UsersState,
|
||||
};
|
||||
|
||||
export interface StoreState {
|
||||
@@ -53,4 +56,5 @@ export interface StoreState {
|
||||
folder: FolderState;
|
||||
dashboard: DashboardState;
|
||||
dataSources: DataSourcesState;
|
||||
users: UsersState;
|
||||
}
|
||||
|
@@ -1,6 +1,44 @@
|
||||
export interface User {
|
||||
export interface OrgUser {
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
lastSeenAt: string;
|
||||
lastSeenAtAge: string;
|
||||
login: string;
|
||||
orgId: number;
|
||||
role: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface Invitee {
|
||||
code: string;
|
||||
createdOn: string;
|
||||
email: string;
|
||||
emailSent: boolean;
|
||||
emailSentOn: string;
|
||||
id: number;
|
||||
invitedByEmail: string;
|
||||
invitedByLogin: string;
|
||||
invitedByName: string;
|
||||
name: string;
|
||||
orgId: number;
|
||||
role: string;
|
||||
status: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface UsersState {
|
||||
users: OrgUser[];
|
||||
invitees: Invitee[];
|
||||
searchQuery: string;
|
||||
canInvite: boolean;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
externalUserMngInfo: string;
|
||||
}
|
||||
|
37
public/app/types/users.ts
Normal file
37
public/app/types/users.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface Invitee {
|
||||
code: string;
|
||||
createdOn: string;
|
||||
email: string;
|
||||
emailSent: boolean;
|
||||
emailSentOn: string;
|
||||
id: number;
|
||||
invitedByEmail: string;
|
||||
invitedByLogin: string;
|
||||
invitedByName: string;
|
||||
name: string;
|
||||
orgId: number;
|
||||
role: string;
|
||||
status: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
lastSeenAt: string;
|
||||
lastSeenAtAge: string;
|
||||
login: string;
|
||||
orgId: number;
|
||||
role: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface UsersState {
|
||||
users: User[];
|
||||
invitees: Invitee[];
|
||||
searchQuery: string;
|
||||
canInvite: boolean;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
externalUserMngInfo: string;
|
||||
}
|
@@ -304,8 +304,7 @@ $graph-tooltip-bg: $dark-1;
|
||||
$checkboxImageUrl: '../img/checkbox.png';
|
||||
|
||||
// info box
|
||||
$info-box-background: linear-gradient(100deg, $blue-dark, darken($blue-dark, 5%));
|
||||
$info-box-color: $gray-4;
|
||||
$info-box-border-color: darken($blue, 12%);
|
||||
|
||||
// footer
|
||||
$footer-link-color: $gray-2;
|
||||
|
@@ -309,8 +309,7 @@ $graph-tooltip-bg: $gray-5;
|
||||
$checkboxImageUrl: '../img/checkbox_white.png';
|
||||
|
||||
// info box
|
||||
$info-box-background: linear-gradient(100deg, $blue, darken($blue, 5%));
|
||||
$info-box-color: $gray-7;
|
||||
$info-box-border-color: lighten($blue, 20%);
|
||||
|
||||
// footer
|
||||
$footer-link-color: $gray-3;
|
||||
|
@@ -1,16 +1,13 @@
|
||||
.grafana-info-box {
|
||||
position: relative;
|
||||
background: $info-box-background;
|
||||
box-shadow: $card-shadow;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
background-color: $empty-list-cta-bg;
|
||||
margin-bottom: 2rem;
|
||||
border-top: 3px solid $info-box-border-color;
|
||||
margin-bottom: $spacer;
|
||||
margin-right: $gf-form-margin;
|
||||
box-shadow: $card-shadow;
|
||||
flex-grow: 1;
|
||||
color: $info-box-color;
|
||||
h5 {
|
||||
color: $info-box-color;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: $spacer;
|
||||
@@ -28,6 +25,10 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@extend .external-link;
|
||||
}
|
||||
|
@@ -210,21 +210,22 @@
|
||||
|
||||
.search-item__body-title {
|
||||
color: $list-item-link-color;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.search-item__body-folder-title {
|
||||
color: $text-color-weak;
|
||||
font-size: $font-size-xs;
|
||||
line-height: 11px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.search-item__icon {
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 19px;
|
||||
line-height: 22px;
|
||||
padding: 5px 2px 5px 10px;
|
||||
padding: 0 2px 0 10px;
|
||||
}
|
||||
|
||||
.search-item__tags {
|
||||
|
Reference in New Issue
Block a user