Merge branch 'master' into react-panels-step1

This commit is contained in:
Torkel Ödegaard
2018-10-12 13:31:20 +02:00
232 changed files with 15596 additions and 2187 deletions

View File

@@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
navModel: {} as NavModel,
apiKeys: [] as ApiKey[],
searchQuery: '',
hasFetched: false,
loadApiKeys: jest.fn(),
deleteApiKey: jest.fn(),
setSearchQuery: jest.fn(),
@@ -35,6 +36,7 @@ describe('Render', () => {
it('should render API keys table', () => {
const { wrapper } = setup({
apiKeys: getMultipleMockKeys(5),
hasFetched: true,
});
expect(wrapper).toMatchSnapshot();

View File

@@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import SlideDown from 'app/core/components/Animations/SlideDown';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
@@ -16,6 +17,7 @@ export interface Props {
navModel: NavModel;
apiKeys: ApiKey[];
searchQuery: string;
hasFetched: boolean;
loadApiKeys: typeof loadApiKeys;
deleteApiKey: typeof deleteApiKey;
setSearchQuery: typeof setSearchQuery;
@@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
});
};
renderTable() {
const { apiKeys } = this.props;
return [
<h3 key="header" className="page-heading">
Existing Keys
</h3>,
<table key="table" className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 && (
<tbody>
{apiKeys.map(key => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</a>
</td>
</tr>
);
})}
</tbody>
)}
</table>,
];
}
render() {
const { newApiKey, isAdding } = this.state;
const { navModel, apiKeys, searchQuery } = this.props;
const { hasFetched, navModel, searchQuery } = this.props;
return (
<div>
@@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
</form>
</div>
</SlideDown>
<h3 className="page-heading">Existing Keys</h3>
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 ? (
<tbody>
{apiKeys.map(key => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</a>
</td>
</tr>
);
})}
</tbody>
) : null}
</table>
{hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
</div>
</div>
);
@@ -209,6 +220,7 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'apikeys'),
apiKeys: getApiKeys(state.apiKeys),
searchQuery: state.apiKeys.searchQuery,
hasFetched: state.apiKeys.hasFetched,
};
}

View File

@@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
</Component>
<h3
className="page-heading"
key="header"
>
Existing Keys
</h3>
<table
className="filter-table"
key="table"
>
<thead>
<tr>
@@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
</form>
</div>
</Component>
<h3
className="page-heading"
>
Existing Keys
</h3>
<table
className="filter-table"
>
<thead>
<tr>
<th>
Name
</th>
<th>
Role
</th>
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
</table>
<PageLoader
pageName="Api keys"
/>
</div>
</div>
`;

View File

@@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
export const initialApiKeysState: ApiKeysState = {
keys: [],
searchQuery: '',
hasFetched: false,
};
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
switch (action.type) {
case ActionTypes.LoadApiKeys:
return { ...state, keys: action.payload };
return { ...state, hasFetched: true, keys: action.payload };
case ActionTypes.SetApiKeysSearchQuery:
return { ...state, searchQuery: action.payload };
}

View File

@@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
const mockKeys = getMultipleMockKeys(5);
it('should return all keys if no search query', () => {
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
const keys = getApiKeys(mockState);
@@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
});
it('should filter keys if search query exists', () => {
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
const keys = getApiKeys(mockState);

View File

@@ -77,6 +77,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
const count = this.props.panel.panels ? this.props.panel.panels.length : 0;
const panels = count === 1 ? 'panel' : 'panels';
const canEdit = this.props.dashboard.meta.canEdit === true;
return (
<div className={classes}>
@@ -87,7 +88,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
({count} {panels})
</span>
</a>
{this.props.dashboard.meta.canEdit === true && (
{canEdit && (
<div className="dashboard-row__actions">
<a className="pointer" onClick={this.openSettings}>
<i className="fa fa-cog" />
@@ -102,7 +103,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
&nbsp;
</div>
)}
<div className="dashboard-row__drag grid-drag-handle" />
{canEdit && <div className="dashboard-row__drag grid-drag-handle" />}
</div>
);
}

View File

@@ -34,6 +34,12 @@ describe('DashboardRow', () => {
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
});
it('should not show row drag handle when cannot edit', () => {
dashboardMock.meta.canEdit = false;
wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0);
});
it('should have zero actions when cannot edit', () => {
dashboardMock.meta.canEdit = false;
panel = new PanelModel({ collapsed: false });

View File

@@ -2,6 +2,7 @@
import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config';
import { DashboardViewState } from '../view_state_srv';
import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => {
const location = {
@@ -11,10 +12,9 @@ describe('when updating view state', () => {
const $scope = {
onAppEvent: jest.fn(() => {}),
dashboard: {
meta: {},
panels: [],
},
dashboard: new DashboardModel({
panels: [{ id: 1 }],
}),
};
let viewState;

View File

@@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
setDataSourcesLayoutMode: jest.fn(),
hasFetched: false,
};
Object.assign(props, propOverrides);
@@ -33,6 +34,7 @@ describe('Render', () => {
const wrapper = setup({
dataSources: getMockDataSources(5),
dataSourcesCount: 5,
hasFetched: true,
});
expect(wrapper).toMatchSnapshot();

View File

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import PageHeader from '../../core/components/PageHeader/PageHeader';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
import DataSourcesList from './DataSourcesList';
@@ -22,6 +23,7 @@ export interface Props {
dataSourcesCount: number;
layoutMode: LayoutMode;
searchQuery: string;
hasFetched: boolean;
loadDataSources: typeof loadDataSources;
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
@@ -56,6 +58,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
searchQuery,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
hasFetched,
} = this.props;
const linkButton = {
@@ -67,10 +70,10 @@ export class DataSourcesListPage extends PureComponent<Props> {
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
{dataSourcesCount === 0 ? (
<EmptyListCTA model={emptyListModel} />
) : (
[
{!hasFetched && <PageLoader pageName="Data sources" />}
{hasFetched && dataSourcesCount === 0 && <EmptyListCTA model={emptyListModel} />}
{hasFetched &&
dataSourcesCount > 0 && [
<OrgActionBar
layoutMode={layoutMode}
searchQuery={searchQuery}
@@ -80,8 +83,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
key="action-bar"
/>,
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
]
)}
]}
</div>
</div>
);
@@ -95,6 +97,7 @@ function mapStateToProps(state) {
layoutMode: getDataSourcesLayoutMode(state.dataSources),
dataSourcesCount: getDataSourcesCount(state.dataSources),
searchQuery: getDataSourcesSearchQuery(state.dataSources),
hasFetched: state.dataSources.hasFetched,
};
}

View File

@@ -0,0 +1,88 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import { NavModel, Plugin } from 'app/types';
import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
import { updateLocation } from '../../core/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getDataSourceTypes } from './state/selectors';
export interface Props {
navModel: NavModel;
dataSourceTypes: Plugin[];
addDataSource: typeof addDataSource;
loadDataSourceTypes: typeof loadDataSourceTypes;
updateLocation: typeof updateLocation;
dataSourceTypeSearchQuery: string;
setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
}
class NewDataSourcePage extends PureComponent<Props> {
componentDidMount() {
this.props.loadDataSourceTypes();
}
onDataSourceTypeClicked = type => {
this.props.addDataSource(type);
};
onSearchQueryChange = event => {
this.props.setDataSourceTypeSearchQuery(event.target.value);
};
render() {
const { navModel, dataSourceTypes, dataSourceTypeSearchQuery } = this.props;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
<h2 className="add-data-source-header">Choose data source type</h2>
<div className="add-data-source-search">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={dataSourceTypeSearchQuery}
onChange={this.onSearchQueryChange}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="add-data-source-grid">
{dataSourceTypes.map((type, index) => {
return (
<div
onClick={() => this.onDataSourceTypeClicked(type)}
className="add-data-source-grid-item"
key={`${type.id}-${index}`}
>
<img className="add-data-source-grid-item-logo" src={type.info.logos.small} />
<span className="add-data-source-grid-item-text">{type.name}</span>
</div>
);
})}
</div>
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
navModel: getNavModel(state.navIndex, 'datasources'),
dataSourceTypes: getDataSourceTypes(state.dataSources),
};
}
const mapDispatchToProps = {
addDataSource,
loadDataSourceTypes,
updateLocation,
setDataSourceTypeSearchQuery,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(NewDataSourcePage));

View File

@@ -155,19 +155,8 @@ exports[`Render should render component 1`] = `
<div
className="page-container page-body"
>
<EmptyListCTA
model={
Object {
"buttonIcon": "gicon gicon-add-datasources",
"buttonLink": "datasources/new",
"buttonTitle": "Add data source",
"proTip": "You can also define data sources through configuration files.",
"proTipLink": "http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list",
"proTipLinkTitle": "Learn more",
"proTipTarget": "_blank",
"title": "There are no data sources defined yet",
}
}
<PageLoader
pageName="Data sources"
/>
</div>
</div>

View File

@@ -0,0 +1,44 @@
import { findNewName, nameExits } from './actions';
import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks';
describe('Name exists', () => {
const plugins = getMockPlugins(5);
it('should be true', () => {
const name = 'pretty cool plugin-1';
expect(nameExits(plugins, name)).toEqual(true);
});
it('should be false', () => {
const name = 'pretty cool plugin-6';
expect(nameExits(plugins, name));
});
});
describe('Find new name', () => {
it('should create a new name', () => {
const plugins = getMockPlugins(5);
const name = 'pretty cool plugin-1';
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
});
it('should create new name without suffix', () => {
const plugin = getMockPlugin();
plugin.name = 'prometheus';
const plugins = [plugin];
const name = 'prometheus';
expect(findNewName(plugins, name)).toEqual('prometheus-1');
});
it('should handle names that end with -', () => {
const plugin = getMockPlugin();
const plugins = [plugin];
const name = 'pretty cool plugin-';
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
});
});

View File

@@ -1,12 +1,16 @@
import { ThunkAction } from 'redux-thunk';
import { DataSource, StoreState } from 'app/types';
import { DataSource, Plugin, StoreState } from 'app/types';
import { getBackendSrv } from '../../../core/services/backend_srv';
import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
import { updateLocation } from '../../../core/actions';
import { UpdateLocationAction } from '../../../core/actions/location';
export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES',
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
}
export interface LoadDataSourcesAction {
@@ -24,11 +28,26 @@ export interface SetDataSourcesLayoutModeAction {
payload: LayoutMode;
}
export interface LoadDataSourceTypesAction {
type: ActionTypes.LoadDataSourceTypes;
payload: Plugin[];
}
export interface SetDataSourceTypeSearchQueryAction {
type: ActionTypes.SetDataSourceTypeSearchQuery;
payload: string;
}
const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources,
payload: dataSources,
});
const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
type: ActionTypes.LoadDataSourceTypes,
payload: dataSourceTypes,
});
export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
type: ActionTypes.SetDataSourcesSearchQuery,
payload: searchQuery,
@@ -39,7 +58,18 @@ export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSources
payload: layoutMode,
});
export type Action = LoadDataSourcesAction | SetDataSourcesSearchQueryAction | SetDataSourcesLayoutModeAction;
export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSearchQueryAction => ({
type: ActionTypes.SetDataSourceTypeSearchQuery,
payload: query,
});
export type Action =
| LoadDataSourcesAction
| SetDataSourcesSearchQueryAction
| SetDataSourcesLayoutModeAction
| UpdateLocationAction
| LoadDataSourceTypesAction
| SetDataSourceTypeSearchQueryAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
@@ -49,3 +79,76 @@ export function loadDataSources(): ThunkResult<void> {
dispatch(dataSourcesLoaded(response));
};
}
export function addDataSource(plugin: Plugin): ThunkResult<void> {
return async (dispatch, getStore) => {
await dispatch(loadDataSources());
const dataSources = getStore().dataSources.dataSources;
const newInstance = {
name: plugin.name,
type: plugin.id,
access: 'proxy',
isDefault: dataSources.length === 0,
};
if (nameExits(dataSources, newInstance.name)) {
newInstance.name = findNewName(dataSources, newInstance.name);
}
const result = await getBackendSrv().post('/api/datasources', newInstance);
dispatch(updateLocation({ path: `/datasources/edit/${result.id}` }));
};
}
export function loadDataSourceTypes(): ThunkResult<void> {
return async dispatch => {
const result = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
dispatch(dataSourceTypesLoaded(result));
};
}
export function nameExits(dataSources, name) {
return (
dataSources.filter(dataSource => {
return dataSource.name === name;
}).length > 0
);
}
export function findNewName(dataSources, name) {
// Need to loop through current data sources to make sure
// the name doesn't exist
while (nameExits(dataSources, name)) {
// If there's a duplicate name that doesn't end with '-x'
// we can add -1 to the name and be done.
if (!nameHasSuffix(name)) {
name = `${name}-1`;
} else {
// if there's a duplicate name that ends with '-x'
// we can try to increment the last digit until the name is unique
// remove the 'x' part and replace it with the new number
name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`;
}
}
return name;
}
function nameHasSuffix(name) {
return name.endsWith('-', name.length - 1);
}
function getLastDigit(name) {
return parseInt(name.slice(-1), 10);
}
function incrementLastDigit(digit) {
return isNaN(digit) ? 1 : digit + 1;
}
function getNewName(name) {
return name.slice(0, name.length - 1);
}

View File

@@ -1,4 +1,4 @@
import { DataSource, DataSourcesState } from 'app/types';
import { DataSource, DataSourcesState, Plugin } from 'app/types';
import { Action, ActionTypes } from './actions';
import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
@@ -7,18 +7,27 @@ const initialState: DataSourcesState = {
layoutMode: LayoutModes.Grid,
searchQuery: '',
dataSourcesCount: 0,
dataSourceTypes: [] as Plugin[],
dataSourceTypeSearchQuery: '',
hasFetched: false,
};
export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
switch (action.type) {
case ActionTypes.LoadDataSources:
return { ...state, dataSources: action.payload, dataSourcesCount: action.payload.length };
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
case ActionTypes.SetDataSourcesSearchQuery:
return { ...state, searchQuery: action.payload };
case ActionTypes.SetDataSourcesLayoutMode:
return { ...state, layoutMode: action.payload };
case ActionTypes.LoadDataSourceTypes:
return { ...state, dataSourceTypes: action.payload };
case ActionTypes.SetDataSourceTypeSearchQuery:
return { ...state, dataSourceTypeSearchQuery: action.payload };
}
return state;

View File

@@ -6,6 +6,14 @@ export const getDataSources = state => {
});
};
export const getDataSourceTypes = state => {
const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i');
return state.dataSourceTypes.filter(type => {
return regex.test(type.name);
});
};
export const getDataSourcesSearchQuery = state => state.searchQuery;
export const getDataSourcesLayoutMode = state => state.layoutMode;
export const getDataSourcesCount = state => state.dataSourcesCount;

View File

@@ -2,13 +2,17 @@ import React from 'react';
import { hot } from 'react-hot-loader';
import Select from 'react-select';
import { ExploreState, 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 ResetStyles from 'app/core/components/Picker/ResetStyles';
import PickerOption from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows';
@@ -61,37 +65,50 @@ interface ExploreProps {
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() {
@@ -101,8 +118,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) {
@@ -146,9 +168,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(
@@ -177,6 +200,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() },
@@ -203,29 +227,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 => {
@@ -237,6 +260,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
};
onClickClear = () => {
this.queryExpressions = [''];
this.setState(
{
graphResult: null,
@@ -255,7 +279,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
onChangeSplit(false);
this.saveState();
}
};
@@ -269,11 +292,9 @@ 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();
}
};
@@ -291,23 +312,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());
}
};
@@ -318,6 +338,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());
};
@@ -335,7 +356,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;
@@ -346,8 +367,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];
});
@@ -362,16 +382,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,
@@ -381,7 +401,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;
}
@@ -403,7 +424,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;
}
@@ -427,7 +449,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;
}
@@ -455,18 +478,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,
@@ -491,11 +523,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore';
const datasources = datasourceSrv.getExploreSources().map(ds => ({
value: ds.name,
label: ds.name,
}));
const selectedDatasource = datasource ? datasource.name : undefined;
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
return (
<div className={exploreClass} ref={this.getRef}>
@@ -517,13 +545,23 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{!datasourceMissing ? (
<div className="navbar-buttons">
<Select
clearable={false}
classNamePrefix={`gf-form-select-box`}
isMulti={false}
isLoading={datasourceLoading}
isClearable={false}
className="gf-form-input gf-form-input--form-dropdown datasource-picker"
onChange={this.onChangeDatasource}
options={datasources}
isOpen={true}
placeholder="Loading datasources..."
options={exploreDatasources}
styles={ResetStyles}
placeholder="Select datasource"
loadingMessage={() => 'Loading datasources...'}
noOptionsMessage={() => 'No datasources found'}
value={selectedDatasource}
components={{
Option: PickerOption,
IndicatorsContainer,
NoOptionsMessage,
}}
/>
</div>
) : null}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import Graph from './Graph';
import { Graph } from './Graph';
import { mockData } from './__mocks__/mockData';
const setup = (propOverrides?: object) => {

View File

@@ -1,6 +1,7 @@
import $ from 'jquery';
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import moment from 'moment';
import { withSize } from 'react-sizeme';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
@@ -68,7 +69,21 @@ const FLOT_OPTIONS = {
// },
};
class Graph extends Component<any, any> {
interface GraphProps {
data: any[];
height?: string; // e.g., '200px'
id?: string;
loading?: boolean;
options: any;
split?: boolean;
size?: { width: number; height: number };
}
interface GraphState {
showAllTimeSeries: boolean;
}
export class Graph extends PureComponent<GraphProps, GraphState> {
state = {
showAllTimeSeries: false,
};
@@ -83,12 +98,13 @@ class Graph extends Component<any, any> {
this.draw();
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: GraphProps) {
if (
prevProps.data !== this.props.data ||
prevProps.options !== this.props.options ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height
prevProps.height !== this.props.height ||
(prevProps.size && prevProps.size.width !== this.props.size.width)
) {
this.draw();
}
@@ -104,7 +120,7 @@ class Graph extends Component<any, any> {
};
draw() {
const { options: userOptions } = this.props;
const { options: userOptions, size } = this.props;
const data = this.getGraphData();
const $el = $(`#${this.props.id}`);
@@ -118,7 +134,7 @@ class Graph extends Component<any, any> {
data: ts.getFlotPairs('null'),
}));
const ticks = $el.width() / 100;
const ticks = (size.width || 0) / 100;
let { from, to } = userOptions.range;
if (!moment.isMoment(from)) {
from = dateMath.parse(from, false);
@@ -147,7 +163,7 @@ class Graph extends Component<any, any> {
}
render() {
const { height, loading } = this.props;
const { height = '100px', id = 'graph', loading = false } = this.props;
const data = this.getGraphData();
if (!loading && data.length === 0) {
@@ -170,7 +186,7 @@ class Graph extends Component<any, any> {
</div>
)}
<div className="panel-container">
<div id={this.props.id} className="explore-graph" style={{ height }} />
<div id={id} className="explore-graph" style={{ height }} />
<Legend data={data} />
</div>
</div>
@@ -178,4 +194,4 @@ class Graph extends Component<any, any> {
}
}
export default Graph;
export default withSize()(Graph);

View File

@@ -1,6 +1,8 @@
import React, { Fragment, PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import { LogsModel, LogRow } from 'app/core/logs_model';
import { LogsModel } from 'app/core/logs_model';
import { findHighlightChunksInText } from 'app/core/utils/text';
interface LogsProps {
className?: string;
@@ -10,34 +12,7 @@ interface LogsProps {
const EXAMPLE_QUERY = '{job="default/prometheus"}';
const Entry: React.SFC<LogRow> = props => {
const { entry, searchMatches } = props;
if (searchMatches && searchMatches.length > 0) {
let lastMatchEnd = 0;
const spans = searchMatches.reduce((acc, match, i) => {
// Insert non-match
if (match.start !== lastMatchEnd) {
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
}
// Match
acc.push(
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
{entry.substr(match.start, match.length)}
</span>
);
lastMatchEnd = match.start + match.length;
// Non-matching end
if (i === searchMatches.length - 1) {
acc.push(<>{entry.slice(lastMatchEnd)}</>);
}
return acc;
}, []);
return <>{spans}</>;
}
return <>{props.entry}</>;
};
export default class Logs extends PureComponent<LogsProps, any> {
export default class Logs extends PureComponent<LogsProps, {}> {
render() {
const { className = '', data } = this.props;
const hasData = data && data.rows && data.rows.length > 0;
@@ -50,7 +25,12 @@ export default class Logs extends PureComponent<LogsProps, any> {
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
<div>
<Entry {...row} />
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
</div>
</Fragment>
))}

View File

@@ -145,7 +145,7 @@ interface PromQueryFieldProps {
onClickHintFix?: (action: any) => void;
onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void;
portalPrefix?: string;
portalOrigin?: string;
request?: (url: string) => any;
supportsLogs?: boolean; // To be removed after Logging gets its own query field
}
@@ -156,7 +156,9 @@ interface PromQueryFieldState {
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
metrics: string[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[];
syntaxLoaded: boolean;
}
interface PromTypeaheadInput {
@@ -167,7 +169,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 +191,8 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
metricsOptions: [],
syntaxLoaded: false,
};
}
@@ -258,10 +262,22 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
};
onReceiveMetrics = () => {
if (!this.state.metrics) {
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
if (!metrics) {
return;
}
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
// Update global prism config
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, 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, syntaxLoaded: true });
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
@@ -294,10 +310,10 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Non-empty but not inside known token
(prefix && !tokenRecognized) ||
(prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
text.match(/[+\-*/^%]/) // After binary operator
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyTypeahead();
}
@@ -453,7 +469,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);
}
});
}
@@ -544,13 +560,8 @@ 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 { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
return (
<div className="prom-query-field">
@@ -570,11 +581,13 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
<TypeaheadField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialValue={this.props.initialQuery}
initialValue={initialQuery}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
portalOrigin="prometheus"
syntaxLoaded={syntaxLoaded}
/>
</div>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}

View File

@@ -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 {
@@ -97,8 +104,9 @@ interface TypeaheadFieldProps {
onValueChanged?: (value: Value) => void;
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
placeholder?: string;
portalPrefix?: string;
portalOrigin?: string;
syntax?: string;
syntaxLoaded?: boolean;
}
export interface TypeaheadFieldState {
@@ -125,7 +133,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,27 +162,44 @@ 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) {
// initialValue is null in case the user typed
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
this.setState({ value: makeValue(nextProps.initialValue, nextProps.syntax) });
componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
this.onChange(
this.state.value
.change()
.insertText(' ')
.deleteBackward()
);
}
}
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 +241,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
wrapperNode,
});
const filteredSuggestions = suggestions
let filteredSuggestions = suggestions
.map(group => {
if (group.items) {
if (prefix) {
@@ -241,6 +266,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 +356,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 +433,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;
}
@@ -435,27 +459,22 @@ 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 { portalOrigin } = this.props;
const { suggestions, typeaheadIndex, typeaheadPrefix } = 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 (
<Portal prefix={portalPrefix}>
<Portal origin={portalOrigin}>
<Typeahead
menuRef={this.menuRef}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
prefix={typeaheadPrefix}
groupedItems={suggestions}
/>
</Portal>
@@ -482,14 +501,14 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
}
}
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
class Portal extends React.PureComponent<{ index?: number; origin: string }, {}> {
node: HTMLElement;
constructor(props) {
super(props);
const { index = 0, prefix = 'query' } = props;
const { index = 0, origin = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}

View File

@@ -44,16 +44,15 @@ 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}
onPressEnter={this.onPressEnter}
onQueryChange={this.onChangeQuery}
@@ -79,7 +78,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 +88,6 @@ export default class QueryRows extends PureComponent<any, {}> {
query={q.query}
queryError={queryErrors[index]}
queryHint={queryHints[index]}
edited={q.edited}
{...handlers}
/>
))}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Highlighter from 'react-highlight-words';
import { Suggestion, SuggestionGroup } from './QueryField';
@@ -16,6 +17,7 @@ interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
@@ -23,7 +25,9 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el);
requestAnimationFrame(() => {
scrollIntoView(this.el);
});
}
}
@@ -36,11 +40,12 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
};
render() {
const { isSelected, item } = this.props;
const { isSelected, item, prefix } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const { label } = item;
return (
<li ref={this.getRef} className={className} onClick={this.onClick}>
{item.detail || item.label}
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" />
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li>
);
@@ -52,18 +57,25 @@ interface TypeaheadGroupProps {
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
prefix?: string;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
render() {
const { items, label, selected, onClickItem } = this.props;
const { items, label, selected, onClickItem, prefix } = this.props;
return (
<li className="typeahead-group">
<div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list">
{items.map(item => {
return (
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
<TypeaheadItem
key={item.label}
onClickItem={onClickItem}
isSelected={selected === item}
item={item}
prefix={prefix}
/>
);
})}
</ul>
@@ -77,14 +89,15 @@ interface TypeaheadProps {
menuRef: any;
selectedItem: Suggestion | null;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
render() {
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return (
<ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
<TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} />
))}
</ul>
);

View File

@@ -38,10 +38,17 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
onChangeSplit = (split: boolean, splitState: ExploreState) => {
this.setState({ split, splitState });
// When closing split, remove URL state for split part
if (!split) {
delete this.urlStates[STATE_KEY_RIGHT];
this.props.updateLocation({
query: this.urlStates,
});
}
};
onSaveState = (key: string, state: ExploreState) => {
const urlState = serializeStateToUrlParam(state);
const urlState = serializeStateToUrlParam(state, true);
this.urlStates[key] = urlState;
this.props.updateLocation({
query: this.urlStates,

View File

@@ -7,9 +7,10 @@ exports[`Render should render component 1`] = `
>
<div
className="explore-graph"
id="graph"
style={
Object {
"height": undefined,
"height": "100px",
}
}
/>
@@ -481,9 +482,10 @@ exports[`Render should render component with disclaimer 1`] = `
>
<div
className="explore-graph"
id="graph"
style={
Object {
"height": undefined,
"height": "100px",
}
}
/>

View File

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

View File

@@ -241,7 +241,6 @@ export class PanelCtrl {
}
render(payload?) {
console.log('panel_ctrl:render');
this.timing.renderStart = new Date().getTime();
this.events.emit('render', payload);
}

View File

@@ -13,22 +13,25 @@ const setup = (propOverrides?: object) => {
setPluginsLayoutMode: jest.fn(),
layoutMode: LayoutModes.Grid,
loadPlugins: jest.fn(),
hasFetched: false,
};
Object.assign(props, propOverrides);
const wrapper = shallow(<PluginListPage {...props} />);
const instance = wrapper.instance() as PluginListPage;
return {
wrapper,
instance,
};
return shallow(<PluginListPage {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render list', () => {
const wrapper = setup({
hasFetched: true,
});
expect(wrapper).toMatchSnapshot();
});

View File

@@ -3,6 +3,7 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import PluginList from './PluginList';
import { NavModel, Plugin } from 'app/types';
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
@@ -15,6 +16,7 @@ export interface Props {
plugins: Plugin[];
layoutMode: LayoutMode;
searchQuery: string;
hasFetched: boolean;
loadPlugins: typeof loadPlugins;
setPluginsLayoutMode: typeof setPluginsLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
@@ -30,12 +32,21 @@ export class PluginListPage extends PureComponent<Props> {
}
render() {
const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
const {
hasFetched,
navModel,
plugins,
layoutMode,
setPluginsLayoutMode,
setPluginsSearchQuery,
searchQuery,
} = this.props;
const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com',
};
return (
<div>
<PageHeader model={navModel} />
@@ -47,7 +58,11 @@ export class PluginListPage extends PureComponent<Props> {
setSearchQuery={query => setPluginsSearchQuery(query)}
linkButton={linkButton}
/>
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
{hasFetched ? (
plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />
) : (
<PageLoader pageName="Plugins" />
)}
</div>
</div>
);
@@ -60,6 +75,7 @@ function mapStateToProps(state) {
plugins: getPlugins(state.plugins),
layoutMode: getLayoutMode(state.plugins),
searchQuery: getPluginsSearchQuery(state.plugins),
hasFetched: state.plugins.hasFetched,
};
}

View File

@@ -1,6 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<OrgActionBar
layoutMode="grid"
linkButton={
Object {
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
"title": "Find more plugins on Grafana.com",
}
}
onSetLayoutMode={[Function]}
searchQuery=""
setSearchQuery={[Function]}
/>
<PageLoader
pageName="Plugins"
/>
</div>
</div>
`;
exports[`Render should render list 1`] = `
<div>
<PageHeader
model={Object {}}

View File

@@ -1,18 +1,13 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<div ng-if="ctrl.current.readOnly" class="page-action-bar">
<div class="grafana-info-box span8">
Disclaimer. This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
</div>
</div>
<h3 class="page-sub-heading">Settings</h3>
<form name="ctrl.editForm" ng-if="ctrl.current">
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Name</span>
<span class="gf-form-label width-10">Name</span>
<input class="gf-form-input max-width-23" type="text" ng-model="ctrl.current.name" placeholder="name" required>
<info-popover offset="0px -135px" mode="right-absolute">
The name is used when you select the data source in panels.
@@ -22,13 +17,6 @@
</div>
<gf-form-switch class="gf-form" label="Default" checked="ctrl.current.isDefault" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">Type</span>
<div class="gf-form-select-wrapper max-width-23">
<select class="gf-form-input" ng-model="ctrl.current.type" ng-options="v.id as v.name for v in ctrl.types" ng-change="ctrl.userChangedType()"></select>
</div>
</div>
</div>
<div class="grafana-info-box" ng-if="ctrl.datasourceMeta.state === 'alpha'">
@@ -66,17 +54,19 @@
</div>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-disabled="ctrl.current.readOnly" ng-click="ctrl.saveChanges()">Save &amp; Test</button>
<button type="submit" class="btn btn-danger" ng-disabled="ctrl.current.readOnly" ng-show="!ctrl.isNew" ng-click="ctrl.delete()">
Delete
</button>
<a class="btn btn-inverse" href="datasources">Back</a>
</div>
<div class="grafana-info-box span8" ng-if="ctrl.current.readOnly">
This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
</div>
<br />
<br />
<br />
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-disabled="ctrl.current.readOnly" ng-click="ctrl.saveChanges()">Save &amp; Test</button>
<button type="submit" class="btn btn-danger" ng-disabled="ctrl.current.readOnly" ng-show="!ctrl.isNew" ng-click="ctrl.delete()">Delete</button>
<a class="btn btn-inverse" href="datasources">Back</a>
</div>
</form>
<br />
<br />
<br />
</form>
</div>

View File

@@ -1,9 +1,9 @@
<div class="gf-form-group">
<h3 class="page-heading">HTTP</h3>
<h3 class="page-heading">HTTP</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">URL</span>
<span class="gf-form-label width-10">URL</span>
<input class="gf-form-input" type="text"
ng-model='current.url' placeholder="{{suggestUrl}}"
bs-typeahead="getSuggestUrls" min-length="0"
@@ -20,140 +20,128 @@
</span>
</info-popover>
</div>
</div>
</div>
<div class="gf-form-inline" ng-if="showAccessOption">
<div class="gf-form max-width-30">
<span class="gf-form-label width-7">Access</span>
<div class="gf-form-select-wrapper max-width-24">
<select class="gf-form-input" ng-model="current.access" ng-options="f.key as f.value for f in [{key: 'proxy', value: 'Server (Default)'}, { key: 'direct', value: 'Browser'}]"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="toggleAccessHelp()">
Help&nbsp;
<i class="fa fa-caret-down" ng-show="showAccessHelp"></i>
<i class="fa fa-caret-right" ng-hide="showAccessHelp">&nbsp;</i>
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="showAccessOption">
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">Access</span>
<div class="gf-form-select-wrapper max-width-24">
<select class="gf-form-input" ng-model="current.access" ng-options="f.key as f.value for f in [{key: 'proxy', value: 'Server (Default)'}, { key: 'direct', value: 'Browser'}]"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="toggleAccessHelp()">
Help&nbsp;
<i class="fa fa-caret-down" ng-show="showAccessHelp"></i>
<i class="fa fa-caret-right" ng-hide="showAccessHelp">&nbsp;</i>
</label>
</div>
</div>
<div class="alert alert-info" ng-show="showAccessHelp">
<div class="alert-body">
<p>
Access mode controls how requests to the data source will be handled.
<strong><i>Server</i></strong> should be the preferred way if nothing else stated.
</p>
<div class="alert-title">Server access mode (Default):</div>
<p>
All requests will be made from the browser to Grafana backend/server which in turn will forward the requests to the data source
and by that circumvent possible Cross-Origin Resource Sharing (CORS) requirements.
The URL needs to be accessible from the grafana backend/server if you select this access mode.
</p>
<div class="alert-title">Browser access mode:</div>
<p>
All requests will be made from the browser directly to the data source and may be subject to
Cross-Origin Resource Sharing (CORS) requirements. The URL needs to be accessible from the browser if you select this
access mode.
</p>
</div>
</div>
</div>
<div class="grafana-info-box m-t-2" ng-show="showAccessHelp">
<p>
Access mode controls how requests to the data source will be handled.
<strong><i>Server</i></strong> should be the preferred way if nothing else stated.
</p>
<div class="alert-title">Server access mode (Default):</div>
<p>
All requests will be made from the browser to Grafana backend/server which in turn will forward the requests to the data source
and by that circumvent possible Cross-Origin Resource Sharing (CORS) requirements.
The URL needs to be accessible from the grafana backend/server if you select this access mode.
</p>
<div class="alert-title">Browser access mode:</div>
<p>
All requests will be made from the browser directly to the data source and may be subject to
Cross-Origin Resource Sharing (CORS) requirements. The URL needs to be accessible from the browser if you select this
access mode.
</div>
<h3 class="page-heading">Auth</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Basic Auth" checked="current.basicAuth" label-class="width-8" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests." checked="current.withCredentials" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="TLS Client Auth" label-class="width-8" checked="current.jsonData.tlsAuth" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="With CA Cert" tooltip="Needed for verifing self-signed TLS Certs" checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
</div>
<div class="gf-form-inline" ng-if="current.access=='proxy'">
<div class="gf-form">
<span class="gf-form-label width-10">Whitelisted Cookies</span>
<bootstrap-tagsinput ng-model="current.jsonData.keepCookies" width-class="width-20" tagclass="label label-tag" placeholder="Add Name">
</bootstrap-tagsinput>
<info-popover mode="right-absolute">
Grafana Proxy deletes forwarded cookies by default. Specify cookies by name that should be forwarded to the data source.
</info-popover>
</div>
</div>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="Skip TLS Verification (Insecure)" label-class="width-16" checked="current.jsonData.tlsSkipVerify" switch-class="max-width-6"></gf-form-switch>
</div>
<h3 class="page-heading">Auth</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Basic Auth" checked="current.basicAuth" label-class="width-10" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests." checked="current.withCredentials" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="TLS Client Auth" label-class="width-10" checked="current.jsonData.tlsAuth" switch-class="max-width-6"></gf-form-switch>
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="With CA Cert" tooltip="Needed for verifing self-signed TLS Certs" checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" ng-if="current.access=='proxy'" label="Skip TLS Verify" label-class="width-10" checked="current.jsonData.tlsSkipVerify" switch-class="max-width-6"></gf-form-switch>
</div>
</div>
<div class="gf-form-group" ng-if="current.basicAuth">
<h6>Basic Auth Details</h6>
<div class="gf-form" ng-if="current.basicAuth">
<span class="gf-form-label width-10">User</span>
<input class="gf-form-input max-width-21" type="text" ng-model='current.basicAuthUser' placeholder="user" required></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Password</span>
<input class="gf-form-input max-width-21" type="password" ng-model='current.basicAuthPassword' placeholder="password" required></input>
</div>
</div>
<div class="gf-form-group" ng-if="(current.jsonData.tlsAuth || current.jsonData.tlsAuthWithCACert) && current.access=='proxy'">
<div class="gf-form">
<h6>TLS Auth Details</h6>
<info-popover mode="header">TLS Certs are encrypted and stored in the Grafana database.</info-popover>
</div>
<div ng-if="current.jsonData.tlsAuthWithCACert">
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">CA Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsCACert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsCACert" placeholder="Begins with -----BEGIN CERTIFICATE-----"></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsCACert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsCACert = false">reset</a>
</div>
</div>
</div>
<div ng-if="current.jsonData.tlsAuth">
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientCert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientCert" placeholder="Begins with -----BEGIN CERTIFICATE-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientCert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientCert = false">reset</a>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Key</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientKey">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientKey" placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientKey">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientKey = false">reset</a>
</div>
</div>
</div>
</div>
<div class="gf-form-group" ng-if="current.basicAuth">
<h6>Basic Auth Details</h6>
<div class="gf-form" ng-if="current.basicAuth">
<span class="gf-form-label width-7">
User
</span>
<input class="gf-form-input max-width-21" type="text" ng-model='current.basicAuthUser' placeholder="user" required></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">
Password
</span>
<input class="gf-form-input max-width-21" type="password" ng-model='current.basicAuthPassword' placeholder="password" required></input>
</div>
</div>
<div class="gf-form-group" ng-if="(current.jsonData.tlsAuth || current.jsonData.tlsAuthWithCACert) && current.access=='proxy'">
<div class="gf-form">
<h6>TLS Auth Details</h6>
<info-popover mode="header">TLS Certs are encrypted and stored in the Grafana database.</info-popover>
</div>
<div ng-if="current.jsonData.tlsAuthWithCACert">
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">CA Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsCACert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsCACert" placeholder="Begins with -----BEGIN CERTIFICATE-----"></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsCACert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsCACert = false">reset</a>
</div>
</div>
</div>
<div ng-if="current.jsonData.tlsAuth">
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Cert</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientCert">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientCert" placeholder="Begins with -----BEGIN CERTIFICATE-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientCert">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientCert = false">reset</a>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form gf-form--v-stretch">
<label class="gf-form-label width-7">Client Key</label>
</div>
<div class="gf-form gf-form--grow" ng-if="!current.secureJsonFields.tlsClientKey">
<textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientKey" placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----" required></textarea>
</div>
<div class="gf-form" ng-if="current.secureJsonFields.tlsClientKey">
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="current.secureJsonFields.tlsClientKey = false">reset</a>
</div>
</div>
</div>
</div>
<h3 class="page-heading" ng-if="current.access=='proxy'">Advanced HTTP Settings</h3>
<div class="gf-form-group" ng-if="current.access=='proxy'">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-10">Whitelisted Cookies</span>
<bootstrap-tagsinput ng-model="current.jsonData.keepCookies" tagclass="label label-tag" placeholder="Add Name">
</bootstrap-tagsinput>
<info-popover mode="right-absolute">
Grafana Proxy deletes forwarded cookies by default. Specify cookies by name that should be forwarded to the data source.
</info-popover>
</div>
</div>
</div>

View File

@@ -6,12 +6,13 @@ export const initialState: PluginsState = {
plugins: [] as Plugin[],
searchQuery: '',
layoutMode: LayoutModes.Grid,
hasFetched: false,
};
export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
switch (action.type) {
case ActionTypes.LoadPlugins:
return { ...state, plugins: action.payload };
return { ...state, hasFetched: true, plugins: action.payload };
case ActionTypes.SetPluginsSearchQuery:
return { ...state, searchQuery: action.payload };

View File

@@ -13,6 +13,7 @@ const setup = (propOverrides?: object) => {
setSearchQuery: jest.fn(),
searchQuery: '',
teamsCount: 0,
hasFetched: false,
};
Object.assign(props, propOverrides);
@@ -36,6 +37,7 @@ describe('Render', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(5),
teamsCount: 5,
hasFetched: true,
});
expect(wrapper).toMatchSnapshot();

View File

@@ -4,6 +4,7 @@ import { hot } from 'react-hot-loader';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { NavModel, Team } from '../../types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
@@ -14,6 +15,7 @@ export interface Props {
teams: Team[];
searchQuery: string;
teamsCount: number;
hasFetched: boolean;
loadTeams: typeof loadTeams;
deleteTeam: typeof deleteTeam;
setSearchQuery: typeof setSearchQuery;
@@ -103,7 +105,7 @@ export class TeamList extends PureComponent<Props, any> {
<div className="page-action-bar__spacer" />
<a className="btn btn-success" href="org/teams/new">
<i className="fa fa-plus" /> New team
New team
</a>
</div>
@@ -125,13 +127,23 @@ export class TeamList extends PureComponent<Props, any> {
);
}
renderList() {
const { teamsCount } = this.props;
if (teamsCount > 0) {
return this.renderTeamList();
} else {
return this.renderEmptyList();
}
}
render() {
const { navModel, teamsCount } = this.props;
const { hasFetched, navModel } = this.props;
return (
<div>
<PageHeader model={navModel} />
{teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
{hasFetched ? this.renderList() : <PageLoader pageName="Teams" />}
</div>
);
}
@@ -143,6 +155,7 @@ function mapStateToProps(state) {
teams: getTeams(state.teams),
searchQuery: getSearchQuery(state.teams),
teamsCount: getTeamsCount(state.teams),
hasFetched: state.teams.hasFetched,
};
}

View File

@@ -83,10 +83,8 @@ export class TeamMembers extends PureComponent<Props, State> {
}
render() {
const { newTeamMember, isAdding } = this.state;
const { isAdding } = this.state;
const { searchMemberQuery, members, syncEnabled } = this.props;
const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
return (
<div>
<div className="page-action-bar">
@@ -117,8 +115,7 @@ export class TeamMembers extends PureComponent<Props, State> {
</button>
<h5>Add Team Member</h5>
<div className="gf-form-inline">
<UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
<UserPicker onSelected={this.onUserSelected} className="width-30" />
{this.state.newTeamMember && (
<button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
Add to team

View File

@@ -5,24 +5,9 @@ exports[`Render should render component 1`] = `
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<EmptyListCTA
model={
Object {
"buttonIcon": "fa fa-plus",
"buttonLink": "org/teams/new",
"buttonTitle": " New team",
"proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
"proTipLink": "",
"proTipLinkTitle": "",
"proTipTarget": "_blank",
"title": "You haven't created any teams yet.",
}
}
/>
</div>
<PageLoader
pageName="Teams"
/>
</div>
`;
@@ -62,10 +47,7 @@ exports[`Render should render teams table 1`] = `
className="btn btn-success"
href="org/teams/new"
>
<i
className="fa fa-plus"
/>
New team
New team
</a>
</div>
<div

View File

@@ -60,7 +60,6 @@ exports[`Render should render component 1`] = `
<UserPicker
className="width-30"
onSelected={[Function]}
value={null}
/>
</div>
</div>
@@ -155,7 +154,6 @@ exports[`Render should render team members 1`] = `
<UserPicker
className="width-30"
onSelected={[Function]}
value={null}
/>
</div>
</div>
@@ -376,7 +374,6 @@ exports[`Render should render team members when sync enabled 1`] = `
<UserPicker
className="width-30"
onSelected={[Function]}
value={null}
/>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
import { Action, ActionTypes } from './actions';
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false };
export const initialTeamState: TeamState = {
team: {} as Team,
members: [] as TeamMember[],
@@ -12,7 +12,7 @@ export const initialTeamState: TeamState = {
export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
switch (action.type) {
case ActionTypes.LoadTeams:
return { ...state, teams: action.payload };
return { ...state, hasFetched: true, teams: action.payload };
case ActionTypes.SetSearchQuery:
return { ...state, searchQuery: action.payload };

View File

@@ -7,7 +7,7 @@ describe('Teams selectors', () => {
const mockTeams = getMultipleMockTeams(5);
it('should return teams if no search query', () => {
const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false };
const teams = getTeams(mockState);
@@ -15,7 +15,7 @@ describe('Teams selectors', () => {
});
it('Should filter teams if search query', () => {
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', hasFetched: false };
const teams = getTeams(mockState);

View File

@@ -71,8 +71,7 @@ export class UsersActionBar extends PureComponent<Props> {
)}
{externalUserMngLinkUrl && (
<a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
<i className="fa fa-external-link-square" />
{externalUserMngLinkName}
<i className="fa fa-external-link-square" /> {externalUserMngLinkName}
</a>
)}
</div>

View File

@@ -22,6 +22,7 @@ const setup = (propOverrides?: object) => {
updateUser: jest.fn(),
removeUser: jest.fn(),
setUsersSearchQuery: jest.fn(),
hasFetched: false,
};
Object.assign(props, propOverrides);
@@ -41,6 +42,14 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
it('should render List page', () => {
const { wrapper } = setup({
hasFetched: true,
});
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {

View File

@@ -1,9 +1,11 @@
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 PageLoader from 'app/core/components/PageLoader/PageLoader';
import UsersActionBar from './UsersActionBar';
import UsersTable from 'app/features/users/UsersTable';
import UsersTable from './UsersTable';
import InviteesTable from './InviteesTable';
import { Invitee, NavModel, OrgUser } from 'app/types';
import appEvents from 'app/core/app_events';
@@ -17,6 +19,7 @@ export interface Props {
users: OrgUser[];
searchQuery: string;
externalUserMngInfo: string;
hasFetched: boolean;
loadUsers: typeof loadUsers;
loadInvitees: typeof loadInvitees;
setUsersSearchQuery: typeof setUsersSearchQuery;
@@ -30,9 +33,20 @@ export interface State {
}
export class UsersListPage extends PureComponent<Props, State> {
state = {
showInvites: false,
};
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();
@@ -75,28 +89,35 @@ export class UsersListPage extends PureComponent<Props, State> {
}));
};
renderTable() {
const { invitees, users } = this.props;
if (this.state.showInvites) {
return <InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />;
} else {
return (
<UsersTable
users={users}
onRoleChange={(role, user) => this.onRoleChange(role, user)}
onRemoveUser={user => this.onRemoveUser(user)}
/>
);
}
}
render() {
const { externalUserMngInfo, invitees, navModel, users } = this.props;
const { navModel, hasFetched } = 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} />
{externalUserMngInfo && (
<div className="grafana-info-box">
<span>{externalUserMngInfo}</span>
</div>
)}
{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)}
/>
{externalUserMngInfoHtml && (
<div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
)}
{hasFetched ? this.renderTable() : <PageLoader pageName="Users" />}
</div>
</div>
);
@@ -110,6 +131,7 @@ function mapStateToProps(state) {
searchQuery: getUsersSearchQuery(state.users),
invitees: getInvitees(state.users),
externalUserMngInfo: state.users.externalUserMngInfo,
hasFetched: state.users.hasFetched,
};
}

View File

@@ -112,6 +112,7 @@ exports[`Render should show external user management button 1`] = `
<i
className="fa fa-external-link-square"
/>
</a>
</div>
</div>

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
exports[`Render should render List page 1`] = `
<div>
<PageHeader
model={Object {}}
@@ -20,3 +20,22 @@ exports[`Render should render component 1`] = `
</div>
</div>
`;
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<Connect(UsersActionBar)
onShowInvites={[Function]}
showInvites={false}
/>
<PageLoader
pageName="Users"
/>
</div>
</div>
`;

View File

@@ -1,6 +1,6 @@
import { Invitee, OrgUser, UsersState } from 'app/types';
import { Action, ActionTypes } from './actions';
import config from '../../../core/config';
import config from 'app/core/config';
export const initialState: UsersState = {
invitees: [] as Invitee[],
@@ -10,15 +10,16 @@ export const initialState: UsersState = {
externalUserMngInfo: config.externalUserMngInfo,
externalUserMngLinkName: config.externalUserMngLinkName,
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
hasFetched: false,
};
export const usersReducer = (state = initialState, action: Action): UsersState => {
switch (action.type) {
case ActionTypes.LoadUsers:
return { ...state, users: action.payload };
return { ...state, hasFetched: true, users: action.payload };
case ActionTypes.LoadInvitees:
return { ...state, invitees: action.payload };
return { ...state, hasFetched: true, invitees: action.payload };
case ActionTypes.SetUsersSearchQuery:
return { ...state, searchQuery: action.payload };