mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into react-panels-step1
This commit is contained in:
@@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
|
||||
navModel: {} as NavModel,
|
||||
apiKeys: [] as ApiKey[],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
loadApiKeys: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
setSearchQuery: jest.fn(),
|
||||
@@ -35,6 +36,7 @@ describe('Render', () => {
|
||||
it('should render API keys table', () => {
|
||||
const { wrapper } = setup({
|
||||
apiKeys: getMultipleMockKeys(5),
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
|
||||
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
||||
import config from 'app/core/config';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@@ -16,6 +17,7 @@ export interface Props {
|
||||
navModel: NavModel;
|
||||
apiKeys: ApiKey[];
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadApiKeys: typeof loadApiKeys;
|
||||
deleteApiKey: typeof deleteApiKey;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
@@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
});
|
||||
};
|
||||
|
||||
renderTable() {
|
||||
const { apiKeys } = this.props;
|
||||
|
||||
return [
|
||||
<h3 key="header" className="page-heading">
|
||||
Existing Keys
|
||||
</h3>,
|
||||
<table key="table" className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 && (
|
||||
<tbody>
|
||||
{apiKeys.map(key => {
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
)}
|
||||
</table>,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
const { newApiKey, isAdding } = this.state;
|
||||
const { navModel, apiKeys, searchQuery } = this.props;
|
||||
const { hasFetched, navModel, searchQuery } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
</form>
|
||||
</div>
|
||||
</SlideDown>
|
||||
|
||||
<h3 className="page-heading">Existing Keys</h3>
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 ? (
|
||||
<tbody>
|
||||
{apiKeys.map(key => {
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
) : null}
|
||||
</table>
|
||||
{hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -209,6 +220,7 @@ function mapStateToProps(state) {
|
||||
navModel: getNavModel(state.navIndex, 'apikeys'),
|
||||
apiKeys: getApiKeys(state.apiKeys),
|
||||
searchQuery: state.apiKeys.searchQuery,
|
||||
hasFetched: state.apiKeys.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
key="header"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
key="table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<PageLoader
|
||||
pageName="Api keys"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
|
||||
export const initialApiKeysState: ApiKeysState = {
|
||||
keys: [],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadApiKeys:
|
||||
return { ...state, keys: action.payload };
|
||||
return { ...state, hasFetched: true, keys: action.payload };
|
||||
case ActionTypes.SetApiKeysSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
|
||||
const mockKeys = getMultipleMockKeys(5);
|
||||
|
||||
it('should return all keys if no search query', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
|
||||
});
|
||||
|
||||
it('should filter keys if search query exists', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div className="dashboard-row__drag grid-drag-handle" />
|
||||
{canEdit && <div className="dashboard-row__drag grid-drag-handle" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
|
||||
searchQuery: '',
|
||||
setDataSourcesSearchQuery: jest.fn(),
|
||||
setDataSourcesLayoutMode: jest.fn(),
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@@ -33,6 +34,7 @@ describe('Render', () => {
|
||||
const wrapper = setup({
|
||||
dataSources: getMockDataSources(5),
|
||||
dataSourcesCount: 5,
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
|
||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
@@ -22,6 +23,7 @@ export interface Props {
|
||||
dataSourcesCount: number;
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadDataSources: typeof loadDataSources;
|
||||
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
||||
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
||||
@@ -56,6 +58,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
searchQuery,
|
||||
setDataSourcesSearchQuery,
|
||||
setDataSourcesLayoutMode,
|
||||
hasFetched,
|
||||
} = this.props;
|
||||
|
||||
const linkButton = {
|
||||
@@ -67,10 +70,10 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
{dataSourcesCount === 0 ? (
|
||||
<EmptyListCTA model={emptyListModel} />
|
||||
) : (
|
||||
[
|
||||
{!hasFetched && <PageLoader pageName="Data sources" />}
|
||||
{hasFetched && dataSourcesCount === 0 && <EmptyListCTA model={emptyListModel} />}
|
||||
{hasFetched &&
|
||||
dataSourcesCount > 0 && [
|
||||
<OrgActionBar
|
||||
layoutMode={layoutMode}
|
||||
searchQuery={searchQuery}
|
||||
@@ -80,8 +83,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
||||
key="action-bar"
|
||||
/>,
|
||||
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
||||
]
|
||||
)}
|
||||
]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -95,6 +97,7 @@ function mapStateToProps(state) {
|
||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
||||
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
||||
hasFetched: state.dataSources.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
88
public/app/features/datasources/NewDataSourcePage.tsx
Normal file
88
public/app/features/datasources/NewDataSourcePage.tsx
Normal 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));
|
||||
@@ -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>
|
||||
|
||||
44
public/app/features/datasources/state/actions.test.ts
Normal file
44
public/app/features/datasources/state/actions.test.ts
Normal 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-');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { Fragment, PureComponent } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { LogsModel, LogRow } from 'app/core/logs_model';
|
||||
import { LogsModel } from 'app/core/logs_model';
|
||||
import { findHighlightChunksInText } from 'app/core/utils/text';
|
||||
|
||||
interface LogsProps {
|
||||
className?: string;
|
||||
@@ -10,34 +12,7 @@ interface LogsProps {
|
||||
|
||||
const EXAMPLE_QUERY = '{job="default/prometheus"}';
|
||||
|
||||
const Entry: React.SFC<LogRow> = props => {
|
||||
const { entry, searchMatches } = props;
|
||||
if (searchMatches && searchMatches.length > 0) {
|
||||
let lastMatchEnd = 0;
|
||||
const spans = searchMatches.reduce((acc, match, i) => {
|
||||
// Insert non-match
|
||||
if (match.start !== lastMatchEnd) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
|
||||
}
|
||||
// Match
|
||||
acc.push(
|
||||
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
|
||||
{entry.substr(match.start, match.length)}
|
||||
</span>
|
||||
);
|
||||
lastMatchEnd = match.start + match.length;
|
||||
// Non-matching end
|
||||
if (i === searchMatches.length - 1) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd)}</>);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return <>{spans}</>;
|
||||
}
|
||||
return <>{props.entry}</>;
|
||||
};
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, any> {
|
||||
export default class Logs extends PureComponent<LogsProps, {}> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
@@ -50,7 +25,12 @@ export default class Logs extends PureComponent<LogsProps, any> {
|
||||
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
|
||||
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
|
||||
<div>
|
||||
<Entry {...row} />
|
||||
<Highlighter
|
||||
textToHighlight={row.entry}
|
||||
searchWords={row.searchWords}
|
||||
findChunks={findHighlightChunksInText}
|
||||
highlightClassName="logs-row-match-highlight"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -145,7 +145,7 @@ interface PromQueryFieldProps {
|
||||
onClickHintFix?: (action: any) => void;
|
||||
onPressEnter?: () => void;
|
||||
onQueryChange?: (value: string, override?: boolean) => void;
|
||||
portalPrefix?: string;
|
||||
portalOrigin?: string;
|
||||
request?: (url: string) => any;
|
||||
supportsLogs?: boolean; // To be removed after Logging gets its own query field
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -13,22 +13,25 @@ const setup = (propOverrides?: object) => {
|
||||
setPluginsLayoutMode: jest.fn(),
|
||||
layoutMode: LayoutModes.Grid,
|
||||
loadPlugins: jest.fn(),
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<PluginListPage {...props} />);
|
||||
const instance = wrapper.instance() as PluginListPage;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
return shallow(<PluginListPage {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list', () => {
|
||||
const wrapper = setup({
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import PluginList from './PluginList';
|
||||
import { NavModel, Plugin } from 'app/types';
|
||||
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
|
||||
@@ -15,6 +16,7 @@ export interface Props {
|
||||
plugins: Plugin[];
|
||||
layoutMode: LayoutMode;
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
loadPlugins: typeof loadPlugins;
|
||||
setPluginsLayoutMode: typeof setPluginsLayoutMode;
|
||||
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
||||
@@ -30,12 +32,21 @@ export class PluginListPage extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
|
||||
const {
|
||||
hasFetched,
|
||||
navModel,
|
||||
plugins,
|
||||
layoutMode,
|
||||
setPluginsLayoutMode,
|
||||
setPluginsSearchQuery,
|
||||
searchQuery,
|
||||
} = this.props;
|
||||
|
||||
const linkButton = {
|
||||
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
|
||||
title: 'Find more plugins on Grafana.com',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
@@ -47,7 +58,11 @@ export class PluginListPage extends PureComponent<Props> {
|
||||
setSearchQuery={query => setPluginsSearchQuery(query)}
|
||||
linkButton={linkButton}
|
||||
/>
|
||||
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
|
||||
{hasFetched ? (
|
||||
plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />
|
||||
) : (
|
||||
<PageLoader pageName="Plugins" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -60,6 +75,7 @@ function mapStateToProps(state) {
|
||||
plugins: getPlugins(state.plugins),
|
||||
layoutMode: getLayoutMode(state.plugins),
|
||||
searchQuery: getPluginsSearchQuery(state.plugins),
|
||||
hasFetched: state.plugins.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<OrgActionBar
|
||||
layoutMode="grid"
|
||||
linkButton={
|
||||
Object {
|
||||
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
|
||||
"title": "Find more plugins on Grafana.com",
|
||||
}
|
||||
}
|
||||
onSetLayoutMode={[Function]}
|
||||
searchQuery=""
|
||||
setSearchQuery={[Function]}
|
||||
/>
|
||||
<PageLoader
|
||||
pageName="Plugins"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render list 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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
|
||||
<i class="fa fa-caret-down" ng-show="showAccessHelp"></i>
|
||||
<i class="fa fa-caret-right" ng-hide="showAccessHelp"> </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
|
||||
<i class="fa fa-caret-down" ng-show="showAccessHelp"></i>
|
||||
<i class="fa fa-caret-right" ng-hide="showAccessHelp"> </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>
|
||||
|
||||
@@ -6,12 +6,13 @@ export const initialState: PluginsState = {
|
||||
plugins: [] as Plugin[],
|
||||
searchQuery: '',
|
||||
layoutMode: LayoutModes.Grid,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadPlugins:
|
||||
return { ...state, plugins: action.payload };
|
||||
return { ...state, hasFetched: true, plugins: action.payload };
|
||||
|
||||
case ActionTypes.SetPluginsSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
@@ -13,6 +13,7 @@ const setup = (propOverrides?: object) => {
|
||||
setSearchQuery: jest.fn(),
|
||||
searchQuery: '',
|
||||
teamsCount: 0,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@@ -36,6 +37,7 @@ describe('Render', () => {
|
||||
const { wrapper } = setup({
|
||||
teams: getMultipleMockTeams(5),
|
||||
teamsCount: 5,
|
||||
hasFetched: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { hot } from 'react-hot-loader';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { NavModel, Team } from '../../types';
|
||||
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
|
||||
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
|
||||
@@ -14,6 +15,7 @@ export interface Props {
|
||||
teams: Team[];
|
||||
searchQuery: string;
|
||||
teamsCount: number;
|
||||
hasFetched: boolean;
|
||||
loadTeams: typeof loadTeams;
|
||||
deleteTeam: typeof deleteTeam;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
@@ -103,7 +105,7 @@ export class TeamList extends PureComponent<Props, any> {
|
||||
<div className="page-action-bar__spacer" />
|
||||
|
||||
<a className="btn btn-success" href="org/teams/new">
|
||||
<i className="fa fa-plus" /> New team
|
||||
New team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -125,13 +127,23 @@ export class TeamList extends PureComponent<Props, any> {
|
||||
);
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const { teamsCount } = this.props;
|
||||
|
||||
if (teamsCount > 0) {
|
||||
return this.renderTeamList();
|
||||
} else {
|
||||
return this.renderEmptyList();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, teamsCount } = this.props;
|
||||
const { hasFetched, navModel } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
{teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
|
||||
{hasFetched ? this.renderList() : <PageLoader pageName="Teams" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +155,7 @@ function mapStateToProps(state) {
|
||||
teams: getTeams(state.teams),
|
||||
searchQuery: getSearchQuery(state.teams),
|
||||
teamsCount: getTeamsCount(state.teams),
|
||||
hasFetched: state.teams.hasFetched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,8 @@ export class TeamMembers extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { newTeamMember, isAdding } = this.state;
|
||||
const { isAdding } = this.state;
|
||||
const { searchMemberQuery, members, syncEnabled } = this.props;
|
||||
const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-action-bar">
|
||||
@@ -117,8 +115,7 @@ export class TeamMembers extends PureComponent<Props, State> {
|
||||
</button>
|
||||
<h5>Add Team Member</h5>
|
||||
<div className="gf-form-inline">
|
||||
<UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
|
||||
|
||||
<UserPicker onSelected={this.onUserSelected} className="width-30" />
|
||||
{this.state.newTeamMember && (
|
||||
<button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
|
||||
Add to team
|
||||
|
||||
@@ -5,24 +5,9 @@ exports[`Render should render component 1`] = `
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<EmptyListCTA
|
||||
model={
|
||||
Object {
|
||||
"buttonIcon": "fa fa-plus",
|
||||
"buttonLink": "org/teams/new",
|
||||
"buttonTitle": " New team",
|
||||
"proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
|
||||
"proTipLink": "",
|
||||
"proTipLinkTitle": "",
|
||||
"proTipTarget": "_blank",
|
||||
"title": "You haven't created any teams yet.",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PageLoader
|
||||
pageName="Teams"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -62,10 +47,7 @@ exports[`Render should render teams table 1`] = `
|
||||
className="btn btn-success"
|
||||
href="org/teams/new"
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
New team
|
||||
New team
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -60,7 +60,6 @@ exports[`Render should render component 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +154,6 @@ exports[`Render should render team members 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +374,6 @@ exports[`Render should render team members when sync enabled 1`] = `
|
||||
<UserPicker
|
||||
className="width-30"
|
||||
onSelected={[Function]}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
|
||||
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
|
||||
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false };
|
||||
export const initialTeamState: TeamState = {
|
||||
team: {} as Team,
|
||||
members: [] as TeamMember[],
|
||||
@@ -12,7 +12,7 @@ export const initialTeamState: TeamState = {
|
||||
export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadTeams:
|
||||
return { ...state, teams: action.payload };
|
||||
return { ...state, hasFetched: true, teams: action.payload };
|
||||
|
||||
case ActionTypes.SetSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('Teams selectors', () => {
|
||||
const mockTeams = getMultipleMockTeams(5);
|
||||
|
||||
it('should return teams if no search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Teams selectors', () => {
|
||||
});
|
||||
|
||||
it('Should filter teams if search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ exports[`Render should show external user management button 1`] = `
|
||||
<i
|
||||
className="fa fa-external-link-square"
|
||||
/>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
exports[`Render should render List page 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
@@ -20,3 +20,22 @@ exports[`Render should render component 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<Connect(UsersActionBar)
|
||||
onShowInvites={[Function]}
|
||||
showInvites={false}
|
||||
/>
|
||||
<PageLoader
|
||||
pageName="Users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Invitee, OrgUser, UsersState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
import config from '../../../core/config';
|
||||
import config from 'app/core/config';
|
||||
|
||||
export const initialState: UsersState = {
|
||||
invitees: [] as Invitee[],
|
||||
@@ -10,15 +10,16 @@ export const initialState: UsersState = {
|
||||
externalUserMngInfo: config.externalUserMngInfo,
|
||||
externalUserMngLinkName: config.externalUserMngLinkName,
|
||||
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
export const usersReducer = (state = initialState, action: Action): UsersState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadUsers:
|
||||
return { ...state, users: action.payload };
|
||||
return { ...state, hasFetched: true, users: action.payload };
|
||||
|
||||
case ActionTypes.LoadInvitees:
|
||||
return { ...state, invitees: action.payload };
|
||||
return { ...state, hasFetched: true, invitees: action.payload };
|
||||
|
||||
case ActionTypes.SetUsersSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
|
||||
Reference in New Issue
Block a user