mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DataSources: refactor datasource pages to be reusable (#51874)
* refactor: move utility functions out of the redux actions * refactor: move password handlers to the feature root * refactor: move API related functions to an api.ts * refactor: move components under a /components folder * refactor: move page containers under a /pages folder and extract components * refactor: update mocks to be easier to reuse * refactor: move tests into a state/tests/ subfolder * refactor: expose 'initialState' for plugins * refactor: move generic types to the root folder of the feature * refactor: import path fixe * refactor: update import paths for app routes * chore: update betterer * refactor: fix type errors due to changed mock functions * chore: fix mocking context_srv in tests * refactor: udpate imports to be more concise * fix: update failing test because of mocks * refactor: use the new `navId` prop where we can * fix: use UID instead ID in datasource edit links * fix:clean up Redux state when unmounting the edit page * refactor: use `uid` instead of `id` * refactor: always fetch the plugin details when editing a datasource The deleted lines could provide performance benefits, although they also make the implementation more prone to errors. (Mostly because we are storing the information about the currently loaded plugin in a single field, and it was not validating if it is for the latest one). We are planning to introduce some kind of caching, but first we would like to clean up the underlying state a bit (plugins & datasources. * fix: add missing dispatch() wrapper for update datasource callback * refactor: prefer using absolute import paths Co-authored-by: Ryan McKinley <ryantxu@gmail.com> * fix: ESLINT import order issue * refactor: put test files next to their files * refactor: use implicit return types for components * fix: remove caching from datasource fetching I have introduced a cache to only fetch data-sources once, however as we are missing a good logic for updating the instances in the Redux store when they change (create, update, delete), this approach is not keeping the UI in sync. Due to this reason I have removed the caching for now, and will reintroduce it once we have a more robust client-side state logic. Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
9f4683b3d0
commit
d6d49d8ba3
@ -4756,16 +4756,16 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
|
||||
],
|
||||
"public/app/features/datasources/DataSourceDashboards.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/datasources/__mocks__/dataSourcesMocks.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/datasources/settings/ButtonRow.tsx:5381": [
|
||||
"public/app/features/datasources/components/ButtonRow.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/datasources/passwordHandlers.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/datasources/passwordHandlers.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/datasources/state/actions.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
@ -4790,12 +4790,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/datasources/utils/passwordHandlers.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/datasources/utils/passwordHandlers.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/dimensions/color.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@ -5466,9 +5460,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||
],
|
||||
"public/app/features/plugins/admin/state/reducer.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/plugins/admin/state/selectors.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
|
@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
import { PluginDashboard } from 'app/types';
|
||||
|
||||
import { DataSourceDashboards, Props } from './DataSourceDashboards';
|
||||
|
||||
const setup = (propOverrides?: Partial<Props>) => {
|
||||
const props: Props = {
|
||||
...getRouteComponentProps(),
|
||||
navModel: { main: { text: 'nav-text' }, node: { text: 'node-text' } },
|
||||
dashboards: [] as PluginDashboard[],
|
||||
dataSource: {} as DataSourceSettings,
|
||||
dataSourceId: 'x',
|
||||
importDashboard: jest.fn(),
|
||||
loadDataSource: jest.fn(),
|
||||
loadPluginDashboards: jest.fn(),
|
||||
removeDashboard: jest.fn(),
|
||||
route: {} as RouteDescriptor,
|
||||
isLoading: false,
|
||||
...propOverrides,
|
||||
};
|
||||
|
||||
return render(<DataSourceDashboards {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render without exploding', () => {
|
||||
expect(() => setup()).not.toThrow();
|
||||
});
|
||||
it('should render component', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'nav-text' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Documentation' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Support' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Community' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,89 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { PluginDashboard, StoreState } from 'app/types';
|
||||
|
||||
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
|
||||
import { loadPluginDashboards } from '../plugins/admin/state/actions';
|
||||
|
||||
import DashboardTable from './DashboardsTable';
|
||||
import { loadDataSource } from './state/actions';
|
||||
import { getDataSource } from './state/selectors';
|
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
const dataSourceId = props.match.params.uid;
|
||||
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, `datasource-dashboards-${dataSourceId}`),
|
||||
dashboards: state.plugins.dashboards,
|
||||
dataSource: getDataSource(state.dataSources, dataSourceId),
|
||||
isLoading: state.plugins.isLoadingPluginDashboards,
|
||||
dataSourceId,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
importDashboard,
|
||||
loadDataSource,
|
||||
loadPluginDashboards,
|
||||
removeDashboard,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
export class DataSourceDashboards extends PureComponent<Props> {
|
||||
async componentDidMount() {
|
||||
const { loadDataSource, dataSourceId } = this.props;
|
||||
await loadDataSource(dataSourceId);
|
||||
this.props.loadPluginDashboards();
|
||||
}
|
||||
|
||||
onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
|
||||
const { dataSource, importDashboard } = this.props;
|
||||
const data: any = {
|
||||
pluginId: dashboard.pluginId,
|
||||
path: dashboard.path,
|
||||
overwrite,
|
||||
inputs: [],
|
||||
};
|
||||
|
||||
if (dataSource) {
|
||||
data.inputs.push({
|
||||
name: '*',
|
||||
type: 'datasource',
|
||||
pluginId: dataSource.type,
|
||||
value: dataSource.name,
|
||||
});
|
||||
}
|
||||
|
||||
importDashboard(data, dashboard.title);
|
||||
};
|
||||
|
||||
onRemove = (dashboard: PluginDashboard) => {
|
||||
this.props.removeDashboard(dashboard.uid);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { dashboards, navModel, isLoading } = this.props;
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<DashboardTable
|
||||
dashboards={dashboards}
|
||||
onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
|
||||
onRemove={(dashboard) => this.onRemove(dashboard)}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connector(DataSourceDashboards);
|
@ -1,54 +0,0 @@
|
||||
// Libraries
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
// Types
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { Card, Tag, useStyles } from '@grafana/ui';
|
||||
|
||||
export type Props = {
|
||||
dataSources: DataSourceSettings[];
|
||||
};
|
||||
|
||||
export const DataSourcesList = ({ dataSources }: Props) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{dataSources.map((dataSource) => {
|
||||
return (
|
||||
<li key={dataSource.id}>
|
||||
<Card href={`datasources/edit/${dataSource.uid}`}>
|
||||
<Card.Heading>{dataSource.name}</Card.Heading>
|
||||
<Card.Figure>
|
||||
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />
|
||||
</Card.Figure>
|
||||
<Card.Meta>
|
||||
{[
|
||||
dataSource.typeName,
|
||||
dataSource.url,
|
||||
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
|
||||
]}
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourcesList;
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
list: css({
|
||||
listStyle: 'none',
|
||||
display: 'grid',
|
||||
// gap: '8px', Add back when legacy support for old Card interface is dropped
|
||||
}),
|
||||
logo: css({
|
||||
objectFit: 'contain',
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { DataSourcesListPageContent } from './DataSourcesListPageContent';
|
||||
|
||||
export const DataSourcesListPage = () => {
|
||||
const navModel = useSelector(({ navIndex }: StoreState) => getNavModel(navIndex, 'datasources'));
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents>
|
||||
<DataSourcesListPageContent />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourcesListPage;
|
@ -1,58 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IconName } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { StoreState, AccessControlAction } from 'app/types';
|
||||
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
import { DataSourcesListHeader } from './DataSourcesListHeader';
|
||||
import { loadDataSources } from './state/actions';
|
||||
import { getDataSourcesCount, getDataSources } from './state/selectors';
|
||||
|
||||
const buttonIcon: IconName = 'database';
|
||||
const emptyListModel = {
|
||||
title: 'No data sources defined',
|
||||
buttonIcon,
|
||||
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',
|
||||
};
|
||||
|
||||
export const DataSourcesListPageContent = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources));
|
||||
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
||||
const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched);
|
||||
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||
const emptyList = {
|
||||
...emptyListModel,
|
||||
buttonDisabled: !canCreateDataSource,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFetched) {
|
||||
dispatch(loadDataSources());
|
||||
}
|
||||
}, [dispatch, hasFetched]);
|
||||
|
||||
if (!hasFetched) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (dataSourcesCount === 0) {
|
||||
return <EmptyListCTA {...emptyList} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataSourcesListHeader />
|
||||
<DataSourcesList dataSources={dataSources} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,244 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { FC, PureComponent } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { DataSourcePluginMeta, GrafanaTheme2, NavModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Card, LinkButton, List, PluginSignatureBadge, FilterInput, useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo';
|
||||
|
||||
import { addDataSource, loadDataSourcePlugins } from './state/actions';
|
||||
import { setDataSourceTypeSearchQuery } from './state/reducers';
|
||||
import { getDataSourcePlugins } from './state/selectors';
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
return {
|
||||
navModel: getNavModel(),
|
||||
plugins: getDataSourcePlugins(state.dataSources),
|
||||
searchQuery: state.dataSources.dataSourceTypeSearchQuery,
|
||||
categories: state.dataSources.categories,
|
||||
isLoading: state.dataSources.isLoadingDataSources,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
addDataSource,
|
||||
loadDataSourcePlugins,
|
||||
setDataSourceTypeSearchQuery,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type Props = ConnectedProps<typeof connector>;
|
||||
|
||||
class NewDataSourcePage extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.props.loadDataSourcePlugins();
|
||||
}
|
||||
|
||||
onDataSourceTypeClicked = (plugin: DataSourcePluginMeta) => {
|
||||
this.props.addDataSource(plugin);
|
||||
};
|
||||
|
||||
onSearchQueryChange = (value: string) => {
|
||||
this.props.setDataSourceTypeSearchQuery(value);
|
||||
};
|
||||
|
||||
renderPlugins(plugins: DataSourcePluginMeta[], id?: string) {
|
||||
if (!plugins || !plugins.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
items={plugins}
|
||||
className={css`
|
||||
> li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
`}
|
||||
getItemKey={(item) => item.id.toString()}
|
||||
renderItem={(item) => (
|
||||
<DataSourceTypeCard
|
||||
plugin={item}
|
||||
onClick={() => this.onDataSourceTypeClicked(item)}
|
||||
onLearnMoreClick={this.onLearnMoreClick}
|
||||
/>
|
||||
)}
|
||||
aria-labelledby={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onLearnMoreClick = (evt: React.SyntheticEvent<HTMLElement>) => {
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
renderCategories() {
|
||||
const { categories } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{categories.map((category) => (
|
||||
<div className="add-data-source-category" key={category.id}>
|
||||
<div className="add-data-source-category__header" id={category.id}>
|
||||
{category.title}
|
||||
</div>
|
||||
{this.renderPlugins(category.plugins, category.id)}
|
||||
</div>
|
||||
))}
|
||||
<div className="add-data-source-more">
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
href="https://grafana.com/plugins?type=datasource&utm_source=grafana_add_ds"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Find more data source plugins on grafana.com
|
||||
</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, isLoading, searchQuery, plugins } = this.props;
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<div className="page-action-bar">
|
||||
<FilterInput value={searchQuery} onChange={this.onSearchQueryChange} placeholder="Filter by name or type" />
|
||||
<div className="page-action-bar__spacer" />
|
||||
<LinkButton href="datasources" fill="outline" variant="secondary" icon="arrow-left">
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
{!searchQuery && <PluginsErrorsInfo />}
|
||||
<div>
|
||||
{searchQuery && this.renderPlugins(plugins)}
|
||||
{!searchQuery && this.renderCategories()}
|
||||
</div>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface DataSourceTypeCardProps {
|
||||
plugin: DataSourcePluginMeta;
|
||||
onClick: () => void;
|
||||
onLearnMoreClick: (evt: React.SyntheticEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
const DataSourceTypeCard: FC<DataSourceTypeCardProps> = (props) => {
|
||||
const { plugin, onLearnMoreClick } = props;
|
||||
const isPhantom = plugin.module === 'phantom';
|
||||
const onClick = !isPhantom && !plugin.unlicensed ? props.onClick : () => {};
|
||||
// find first plugin info link
|
||||
const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Card className={cx(styles.card, 'card-parent')} onClick={onClick}>
|
||||
<Card.Heading
|
||||
className={styles.heading}
|
||||
aria-label={selectors.pages.AddDataSource.dataSourcePluginsV2(plugin.name)}
|
||||
>
|
||||
{plugin.name}
|
||||
</Card.Heading>
|
||||
<Card.Figure align="center" className={styles.figure}>
|
||||
<img className={styles.logo} src={plugin.info.logos.small} alt="" />
|
||||
</Card.Figure>
|
||||
<Card.Description className={styles.description}>{plugin.info.description}</Card.Description>
|
||||
{!isPhantom && (
|
||||
<Card.Meta className={styles.meta}>
|
||||
<PluginSignatureBadge status={plugin.signature} />
|
||||
</Card.Meta>
|
||||
)}
|
||||
<Card.Actions className={styles.actions}>
|
||||
{learnMoreLink && (
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={onLearnMoreClick}
|
||||
icon="external-link-alt"
|
||||
aria-label={`${plugin.name}, learn more.`}
|
||||
>
|
||||
{learnMoreLink.name}
|
||||
</LinkButton>
|
||||
)}
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
heading: css({
|
||||
fontSize: theme.v1.typography.heading.h5,
|
||||
fontWeight: 'inherit',
|
||||
}),
|
||||
figure: css({
|
||||
width: 'inherit',
|
||||
marginRight: '0px',
|
||||
'> img': {
|
||||
width: theme.spacing(7),
|
||||
},
|
||||
}),
|
||||
meta: css({
|
||||
marginTop: '6px',
|
||||
position: 'relative',
|
||||
}),
|
||||
description: css({
|
||||
margin: '0px',
|
||||
fontSize: theme.typography.size.sm,
|
||||
}),
|
||||
actions: css({
|
||||
position: 'relative',
|
||||
alignSelf: 'center',
|
||||
marginTop: '0px',
|
||||
opacity: 0,
|
||||
|
||||
'.card-parent:hover &, .card-parent:focus-within &': {
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
card: css({
|
||||
gridTemplateAreas: `
|
||||
"Figure Heading Actions"
|
||||
"Figure Description Actions"
|
||||
"Figure Meta Actions"
|
||||
"Figure - Actions"`,
|
||||
}),
|
||||
logo: css({
|
||||
marginRight: theme.v1.spacing.lg,
|
||||
marginLeft: theme.v1.spacing.sm,
|
||||
width: theme.spacing(7),
|
||||
maxHeight: theme.spacing(7),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNavModel(): NavModel {
|
||||
const main = {
|
||||
icon: 'database',
|
||||
id: 'datasource-new',
|
||||
text: 'Add data source',
|
||||
href: 'datasources/new',
|
||||
subTitle: 'Choose a data source type',
|
||||
};
|
||||
|
||||
return {
|
||||
main: main,
|
||||
node: main,
|
||||
};
|
||||
}
|
||||
|
||||
export default connector(NewDataSourcePage);
|
@ -1,48 +1,100 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
export const getMockDataSources = (amount: number) => {
|
||||
const dataSources = [];
|
||||
import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data';
|
||||
import { DataSourceSettingsState, PluginDashboard } from 'app/types';
|
||||
|
||||
for (let i = 0; i < amount; i++) {
|
||||
dataSources.push({
|
||||
export const getMockDashboard = (override?: Partial<PluginDashboard>) => ({
|
||||
uid: 'G1btqkgkK',
|
||||
pluginId: 'grafana-timestream-datasource',
|
||||
title: 'Sample (DevOps)',
|
||||
imported: true,
|
||||
importedUri: 'db/sample-devops',
|
||||
importedUrl: '/d/G1btqkgkK/sample-devops',
|
||||
slug: '',
|
||||
dashboardId: 12,
|
||||
folderId: 0,
|
||||
importedRevision: 1,
|
||||
revision: 1,
|
||||
description: '',
|
||||
path: 'dashboards/sample.json',
|
||||
removed: false,
|
||||
...override,
|
||||
});
|
||||
|
||||
export const getMockDataSources = (amount: number, overrides?: Partial<DataSourceSettings>): DataSourceSettings[] =>
|
||||
[...Array(amount)].map((_, i) =>
|
||||
getMockDataSource({
|
||||
...overrides,
|
||||
id: i,
|
||||
uid: `uid-${i}`,
|
||||
database: overrides?.database ? `${overrides.database}-${i}` : `database-${i}`,
|
||||
name: overrides?.name ? `${overrides.name}-${i}` : `dataSource-${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
export const getMockDataSource = <T>(overrides?: Partial<DataSourceSettings<T>>): DataSourceSettings<T> =>
|
||||
merge(
|
||||
{
|
||||
access: '',
|
||||
basicAuth: false,
|
||||
database: `database-${i}`,
|
||||
id: i,
|
||||
basicAuthUser: '',
|
||||
withCredentials: false,
|
||||
database: '',
|
||||
id: 13,
|
||||
uid: 'x',
|
||||
isDefault: false,
|
||||
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
|
||||
name: `dataSource-${i}`,
|
||||
name: 'gdev-cloudwatch',
|
||||
typeName: 'Cloudwatch',
|
||||
orgId: 1,
|
||||
readOnly: false,
|
||||
type: 'cloudwatch',
|
||||
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
|
||||
url: '',
|
||||
user: '',
|
||||
});
|
||||
}
|
||||
secureJsonFields: {},
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
return dataSources as DataSourceSettings[];
|
||||
};
|
||||
export const getMockDataSourceMeta = (overrides?: Partial<DataSourcePluginMeta>): DataSourcePluginMeta =>
|
||||
merge(
|
||||
{
|
||||
id: 0,
|
||||
name: 'datasource-test',
|
||||
type: 'datasource',
|
||||
info: {
|
||||
author: {
|
||||
name: 'Sample Author',
|
||||
url: 'https://sample-author.com',
|
||||
},
|
||||
description: 'Some sample description.',
|
||||
links: [{ name: 'Website', url: 'https://sample-author.com' }],
|
||||
logos: {
|
||||
large: 'large-logo',
|
||||
small: 'small-logo',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2022-07-01',
|
||||
version: '1.5.0',
|
||||
},
|
||||
|
||||
export const getMockDataSource = (): DataSourceSettings => {
|
||||
return {
|
||||
access: '',
|
||||
basicAuth: false,
|
||||
basicAuthUser: '',
|
||||
withCredentials: false,
|
||||
database: '',
|
||||
id: 13,
|
||||
uid: 'x',
|
||||
isDefault: false,
|
||||
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
|
||||
name: 'gdev-cloudwatch',
|
||||
typeName: 'Cloudwatch',
|
||||
orgId: 1,
|
||||
readOnly: false,
|
||||
type: 'cloudwatch',
|
||||
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
|
||||
url: '',
|
||||
user: '',
|
||||
secureJsonFields: {},
|
||||
};
|
||||
};
|
||||
module: 'plugins/datasource-test/module',
|
||||
baseUrl: 'public/plugins/datasource-test',
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
export const getMockDataSourceSettingsState = (overrides?: Partial<DataSourceSettingsState>): DataSourceSettingsState =>
|
||||
merge(
|
||||
{
|
||||
plugin: {
|
||||
meta: getMockDataSourceMeta(),
|
||||
components: {},
|
||||
},
|
||||
testingStatus: {},
|
||||
loadError: null,
|
||||
loading: false,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
2
public/app/features/datasources/__mocks__/index.ts
Normal file
2
public/app/features/datasources/__mocks__/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dataSourcesMocks';
|
||||
export * from './store.navIndex.mock';
|
@ -1,6 +1,6 @@
|
||||
import { NavSection } from '@grafana/data';
|
||||
import { NavSection, NavIndex } from '@grafana/data';
|
||||
|
||||
export default {
|
||||
export const navIndex: NavIndex = {
|
||||
dashboards: {
|
||||
id: 'dashboards',
|
||||
text: 'Dashboards',
|
||||
|
37
public/app/features/datasources/api.test.ts
Normal file
37
public/app/features/datasources/api.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { BackendSrvRequest, FetchResponse } from '@grafana/runtime';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { getDataSourceByIdOrUid } from './api';
|
||||
|
||||
jest.mock('app/core/services/backend_srv');
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...(jest.requireActual('@grafana/runtime') as unknown as object),
|
||||
getBackendSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResponse = (response: Partial<FetchResponse>) => {
|
||||
(getBackendSrv as jest.Mock).mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(response as FetchResponse);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Datasources / API', () => {
|
||||
describe('getDataSourceByIdOrUid()', () => {
|
||||
it('should resolve to the datasource object in case it is fetched using a UID', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 111,
|
||||
uid: 'abcdefg',
|
||||
},
|
||||
};
|
||||
mockResponse(response);
|
||||
|
||||
expect(await getDataSourceByIdOrUid(response.data.uid)).toBe(response.data);
|
||||
});
|
||||
});
|
||||
});
|
74
public/app/features/datasources/api.ts
Normal file
74
public/app/features/datasources/api.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
|
||||
export const getDataSources = async (): Promise<DataSourceSettings[]> => {
|
||||
return await getBackendSrv().get('/api/datasources');
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `getDataSourceByUid` instead.
|
||||
*/
|
||||
export const getDataSourceById = async (id: string) => {
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
method: 'GET',
|
||||
url: `/api/datasources/${id}`,
|
||||
params: accessControlQueryParam(),
|
||||
showErrorAlert: false,
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw Error(`Could not find data source by ID: "${id}"`);
|
||||
};
|
||||
|
||||
export const getDataSourceByUid = async (uid: string) => {
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
method: 'GET',
|
||||
url: `/api/datasources/uid/${uid}`,
|
||||
params: accessControlQueryParam(),
|
||||
showErrorAlert: false,
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw Error(`Could not find data source by UID: "${uid}"`);
|
||||
};
|
||||
|
||||
export const getDataSourceByIdOrUid = async (idOrUid: string) => {
|
||||
// Try with UID first, as we are trying to migrate to that
|
||||
try {
|
||||
return await getDataSourceByUid(idOrUid);
|
||||
} catch (err) {
|
||||
console.log(`Failed to lookup data source using UID "${idOrUid}"`);
|
||||
}
|
||||
|
||||
// Try using ID
|
||||
try {
|
||||
return await getDataSourceById(idOrUid);
|
||||
} catch (err) {
|
||||
console.log(`Failed to lookup data source using ID "${idOrUid}"`);
|
||||
}
|
||||
|
||||
throw Error('Could not find data source');
|
||||
};
|
||||
|
||||
export const createDataSource = (dataSource: Partial<DataSourceSettings>) =>
|
||||
getBackendSrv().post('/api/datasources', dataSource);
|
||||
|
||||
export const getDataSourcePlugins = () => getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
|
||||
|
||||
export const updateDataSource = (dataSource: DataSourceSettings) =>
|
||||
getBackendSrv().put(`/api/datasources/uid/${dataSource.uid}`, dataSource);
|
||||
|
||||
export const deleteDataSource = (uid: string) => getBackendSrv().delete(`/api/datasources/uid/${uid}`);
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import BasicSettings, { Props } from './BasicSettings';
|
||||
import { BasicSettings, Props } from './BasicSettings';
|
||||
|
||||
const setup = () => {
|
||||
const props: Props = {
|
||||
@ -16,7 +16,7 @@ const setup = () => {
|
||||
return render(<BasicSettings {...props} />);
|
||||
};
|
||||
|
||||
describe('Basic Settings', () => {
|
||||
describe('<BasicSettings>', () => {
|
||||
it('should render component', () => {
|
||||
setup();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { InlineField, InlineSwitch, Input } from '@grafana/ui';
|
||||
@ -10,10 +10,11 @@ export interface Props {
|
||||
onDefaultChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
|
||||
export function BasicSettings({ dataSourceName, isDefault, onDefaultChange, onNameChange }: Props) {
|
||||
return (
|
||||
<div className="gf-form-group" aria-label="Datasource settings page basic settings">
|
||||
<div className="gf-form-inline">
|
||||
{/* Name */}
|
||||
<div className="gf-form max-width-30">
|
||||
<InlineField
|
||||
label="Name"
|
||||
@ -33,6 +34,7 @@ const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange,
|
||||
</InlineField>
|
||||
</div>
|
||||
|
||||
{/* Is Default */}
|
||||
<InlineField label="Default" labelWidth={8}>
|
||||
<InlineSwitch
|
||||
id="basic-settings-default"
|
||||
@ -45,6 +47,4 @@ const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange,
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicSettings;
|
||||
}
|
@ -3,15 +3,7 @@ import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import ButtonRow, { Props } from './ButtonRow';
|
||||
|
||||
jest.mock('app/core/core', () => {
|
||||
return {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
},
|
||||
};
|
||||
});
|
||||
import { ButtonRow, Props } from './ButtonRow';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
@ -28,7 +20,7 @@ const setup = (propOverrides?: object) => {
|
||||
return render(<ButtonRow {...props} />);
|
||||
};
|
||||
|
||||
describe('Button Row', () => {
|
||||
describe('<ButtonRow>', () => {
|
||||
it('should render component', () => {
|
||||
setup();
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Button, LinkButton } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction } from 'app/types/';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
exploreUrl: string;
|
||||
@ -14,7 +14,7 @@ export interface Props {
|
||||
onTest: (event: any) => void;
|
||||
}
|
||||
|
||||
const ButtonRow: FC<Props> = ({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }) => {
|
||||
export function ButtonRow({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }: Props) {
|
||||
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
|
||||
|
||||
return (
|
||||
@ -46,12 +46,10 @@ const ButtonRow: FC<Props> = ({ canSave, canDelete, onDelete, onSubmit, onTest,
|
||||
</Button>
|
||||
)}
|
||||
{!canSave && (
|
||||
<Button type="submit" variant="primary" onClick={onTest}>
|
||||
<Button variant="primary" onClick={onTest}>
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonRow;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { GrafanaEdition } from '@grafana/data/src/types/config';
|
||||
@ -12,7 +12,7 @@ export interface Props {
|
||||
dataSource: DataSourceSettings;
|
||||
}
|
||||
|
||||
export const CloudInfoBox: FC<Props> = ({ dataSource }) => {
|
||||
export function CloudInfoBox({ dataSource }: Props) {
|
||||
let mainDS = '';
|
||||
let extraDS = '';
|
||||
|
||||
@ -71,4 +71,4 @@ export const CloudInfoBox: FC<Props> = ({ dataSource }) => {
|
||||
}}
|
||||
</LocalStorageValueProvider>
|
||||
);
|
||||
};
|
||||
}
|
@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PluginDashboard } from '../../types';
|
||||
import { PluginDashboard } from 'app/types';
|
||||
|
||||
import DashboardsTable, { Props } from './DashboardsTable';
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Icon } from '@grafana/ui';
|
||||
|
||||
import { PluginDashboard } from '../../types';
|
||||
import { PluginDashboard } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
// List of plugin dashboards to show in the table
|
||||
dashboards: PluginDashboard[];
|
||||
// Callback used when the user clicks on importing a dashboard
|
||||
onImport: (dashboard: PluginDashboard, overwrite: boolean) => void;
|
||||
// Callback used when the user clicks on removing a dashboard
|
||||
onRemove: (dashboard: PluginDashboard) => void;
|
||||
}
|
||||
|
||||
const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {
|
||||
export function DashboardsTable({ dashboards, onImport, onRemove }: Props) {
|
||||
function buttonText(dashboard: PluginDashboard) {
|
||||
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
|
||||
}
|
||||
@ -57,6 +59,6 @@ const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default DashboardsTable;
|
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourcePluginMeta } from '@grafana/data';
|
||||
import { LinkButton } from '@grafana/ui';
|
||||
import { DataSourcePluginCategory } from 'app/types';
|
||||
|
||||
import { DataSourceTypeCardList } from './DataSourceTypeCardList';
|
||||
|
||||
export type Props = {
|
||||
// The list of data-source plugin categories to display
|
||||
categories: DataSourcePluginCategory[];
|
||||
|
||||
// Called when a data-source plugin is clicked on in the list
|
||||
onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void;
|
||||
};
|
||||
|
||||
export function DataSourceCategories({ categories, onClickDataSourceType }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Categories */}
|
||||
{categories.map(({ id, title, plugins }) => (
|
||||
<div className="add-data-source-category" key={id}>
|
||||
<div className="add-data-source-category__header" id={id}>
|
||||
{title}
|
||||
</div>
|
||||
<DataSourceTypeCardList dataSourcePlugins={plugins} onClickDataSourceType={onClickDataSourceType} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Find more */}
|
||||
<div className="add-data-source-more">
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
href="https://grafana.com/plugins?type=datasource&utm_source=grafana_add_ds"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Find more data source plugins on grafana.com
|
||||
</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { getMockDashboard } from '../__mocks__';
|
||||
|
||||
import { DataSourceDashboardsView, ViewProps } from './DataSourceDashboards';
|
||||
|
||||
const setup = ({
|
||||
dashboards = [],
|
||||
isLoading = false,
|
||||
onImportDashboard = jest.fn(),
|
||||
onRemoveDashboard = jest.fn(),
|
||||
}: Partial<ViewProps>) => {
|
||||
const store = configureStore();
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<DataSourceDashboardsView
|
||||
isLoading={isLoading}
|
||||
dashboards={dashboards}
|
||||
onImportDashboard={onImportDashboard}
|
||||
onRemoveDashboard={onRemoveDashboard}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<DataSourceDashboards>', () => {
|
||||
it('should show a loading indicator while loading', () => {
|
||||
setup({ isLoading: true });
|
||||
|
||||
expect(screen.queryByText(/loading/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not show a loading indicator when loaded', () => {
|
||||
setup({ isLoading: false });
|
||||
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a list of dashboards once loaded', () => {
|
||||
setup({
|
||||
dashboards: [getMockDashboard({ title: 'My Dashboard 1' }), getMockDashboard({ title: 'My Dashboard 2' })],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('My Dashboard 1')).toBeVisible();
|
||||
expect(screen.queryByText('My Dashboard 2')).toBeVisible();
|
||||
});
|
||||
});
|
@ -0,0 +1,85 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { importDashboard, removeDashboard } from 'app/features/dashboard/state/actions';
|
||||
import { loadPluginDashboards } from 'app/features/plugins/admin/state/actions';
|
||||
import { PluginDashboard, StoreState } from 'app/types';
|
||||
|
||||
import DashboardTable from '../components/DashboardsTable';
|
||||
import { useLoadDataSource } from '../state';
|
||||
|
||||
export type Props = {
|
||||
// The UID of the data source
|
||||
uid: string;
|
||||
};
|
||||
|
||||
export function DataSourceDashboards({ uid }: Props) {
|
||||
useLoadDataSource(uid);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dataSource = useSelector((s: StoreState) => s.dataSources.dataSource);
|
||||
const dashboards = useSelector((s: StoreState) => s.plugins.dashboards);
|
||||
const isLoading = useSelector((s: StoreState) => s.plugins.isLoadingPluginDashboards);
|
||||
|
||||
useEffect(() => {
|
||||
// Load plugin dashboards only when the datasource has loaded
|
||||
if (dataSource.id > 0) {
|
||||
dispatch(loadPluginDashboards());
|
||||
}
|
||||
}, [dispatch, dataSource]);
|
||||
|
||||
const onImportDashboard = (dashboard: PluginDashboard, overwrite: boolean) => {
|
||||
dispatch(
|
||||
importDashboard(
|
||||
{
|
||||
pluginId: dashboard.pluginId,
|
||||
path: dashboard.path,
|
||||
overwrite,
|
||||
inputs: [
|
||||
{
|
||||
name: '*',
|
||||
type: 'datasource',
|
||||
pluginId: dataSource.type,
|
||||
value: dataSource.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
dashboard.title
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onRemoveDashboard = ({ uid }: PluginDashboard) => {
|
||||
dispatch(removeDashboard(uid));
|
||||
};
|
||||
|
||||
return (
|
||||
<DataSourceDashboardsView
|
||||
dashboards={dashboards}
|
||||
isLoading={isLoading}
|
||||
onImportDashboard={onImportDashboard}
|
||||
onRemoveDashboard={onRemoveDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewProps = {
|
||||
isLoading: boolean;
|
||||
dashboards: PluginDashboard[];
|
||||
onImportDashboard: (dashboard: PluginDashboard, overwrite: boolean) => void;
|
||||
onRemoveDashboard: (dashboard: PluginDashboard) => void;
|
||||
};
|
||||
|
||||
export const DataSourceDashboardsView = ({
|
||||
isLoading,
|
||||
dashboards,
|
||||
onImportDashboard,
|
||||
onRemoveDashboard,
|
||||
}: ViewProps) => {
|
||||
if (isLoading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return <DashboardTable dashboards={dashboards} onImport={onImportDashboard} onRemove={onRemoveDashboard} />;
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
import { DataSourceRights } from '../types';
|
||||
|
||||
import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage';
|
||||
|
||||
export type Props = {
|
||||
dataSourceRights: DataSourceRights;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function DataSourceLoadError({ dataSourceRights, onDelete }: Props) {
|
||||
const { readOnly, hasDeleteRights } = dataSourceRights;
|
||||
const canDelete = !readOnly && hasDeleteRights;
|
||||
const navigateBack = () => history.back();
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly && <DataSourceReadOnlyMessage />}
|
||||
|
||||
<div className="gf-form-button-row">
|
||||
{canDelete && (
|
||||
<Button type="submit" variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" fill="outline" type="button" onClick={navigateBack}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Alert } from '@grafana/ui';
|
||||
|
||||
export const missingRightsMessage =
|
||||
'You are not allowed to modify this data source. Please contact your server admin to update this data source.';
|
||||
|
||||
export function DataSourceMissingRightsMessage() {
|
||||
return (
|
||||
<Alert severity="info" title="Missing rights">
|
||||
{missingRightsMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
export type Props = {
|
||||
plugin?: GenericDataSourcePlugin | null;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export function DataSourcePluginConfigPage({ plugin, pageId }: Props) {
|
||||
if (!plugin || !plugin.configPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = plugin.configPages.find(({ id }) => id === pageId);
|
||||
|
||||
if (page) {
|
||||
// TODO: Investigate if any plugins are using this? We should change this interface
|
||||
return <page.body plugin={plugin} query={{}} />;
|
||||
}
|
||||
|
||||
return <div>Page not found: {page}</div>;
|
||||
}
|
@ -1,17 +1,10 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import {
|
||||
DataQuery,
|
||||
DataSourceApi,
|
||||
DataSourceJsonData,
|
||||
DataSourcePlugin,
|
||||
DataSourcePluginMeta,
|
||||
DataSourceSettings,
|
||||
} from '@grafana/data';
|
||||
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
|
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
|
||||
|
||||
export type GenericDataSourcePlugin = DataSourcePlugin<DataSourceApi<DataQuery, DataSourceJsonData>>;
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
export interface Props {
|
||||
plugin: GenericDataSourcePlugin;
|
||||
@ -20,7 +13,7 @@ export interface Props {
|
||||
onModelChange: (dataSource: DataSourceSettings) => void;
|
||||
}
|
||||
|
||||
export class PluginSettings extends PureComponent<Props> {
|
||||
export class DataSourcePluginSettings extends PureComponent<Props> {
|
||||
element: HTMLDivElement | null = null;
|
||||
component?: AngularComponent;
|
||||
scopeProps: {
|
||||
@ -92,5 +85,3 @@ export class PluginSettings extends PureComponent<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginSettings;
|
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginState } from '@grafana/data';
|
||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
||||
|
||||
export type Props = {
|
||||
state?: PluginState;
|
||||
};
|
||||
|
||||
export function DataSourcePluginState({ state }: Props) {
|
||||
return (
|
||||
<div className="gf-form">
|
||||
<label className="gf-form-label width-10">Plugin state</label>
|
||||
<label className="gf-form-label gf-form-label--transparent">
|
||||
<PluginStateInfo state={state} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Alert } from '@grafana/ui';
|
||||
|
||||
export const readOnlyMessage =
|
||||
'This data source was added by config and cannot be modified using the UI. Please contact your server admin to update this data source.';
|
||||
|
||||
export function DataSourceReadOnlyMessage() {
|
||||
return (
|
||||
<Alert aria-label={e2eSelectors.pages.DataSource.readOnly} severity="info" title="Provisioned data source">
|
||||
{readOnlyMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { TestingStatus } from 'app/types';
|
||||
|
||||
export type Props = {
|
||||
testingStatus?: TestingStatus;
|
||||
};
|
||||
|
||||
export function DataSourceTestingStatus({ testingStatus }: Props) {
|
||||
const isError = testingStatus?.status === 'error';
|
||||
const message = testingStatus?.message;
|
||||
const detailsMessage = testingStatus?.details?.message;
|
||||
const detailsVerboseMessage = testingStatus?.details?.verboseMessage;
|
||||
|
||||
if (message) {
|
||||
return (
|
||||
<div className="gf-form-group p-t-2">
|
||||
<Alert
|
||||
severity={isError ? 'error' : 'success'}
|
||||
title={message}
|
||||
aria-label={e2eSelectors.pages.DataSource.alert}
|
||||
>
|
||||
{testingStatus?.details && (
|
||||
<>
|
||||
{detailsMessage}
|
||||
{detailsVerboseMessage ? (
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>{detailsVerboseMessage}</details>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourcePluginMeta, GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Card, LinkButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export type Props = {
|
||||
dataSourcePlugin: DataSourcePluginMeta;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function DataSourceTypeCard({ onClick, dataSourcePlugin }: Props) {
|
||||
const isPhantom = dataSourcePlugin.module === 'phantom';
|
||||
const isClickable = !isPhantom && !dataSourcePlugin.unlicensed;
|
||||
const learnMoreLink = dataSourcePlugin.info?.links?.length > 0 ? dataSourcePlugin.info.links[0] : null;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Card className={cx(styles.card, 'card-parent')} onClick={isClickable ? onClick : () => {}}>
|
||||
{/* Name */}
|
||||
<Card.Heading
|
||||
className={styles.heading}
|
||||
aria-label={e2eSelectors.pages.AddDataSource.dataSourcePluginsV2(dataSourcePlugin.name)}
|
||||
>
|
||||
{dataSourcePlugin.name}
|
||||
</Card.Heading>
|
||||
|
||||
{/* Logo */}
|
||||
<Card.Figure align="center" className={styles.figure}>
|
||||
<img className={styles.logo} src={dataSourcePlugin.info.logos.small} alt="" />
|
||||
</Card.Figure>
|
||||
|
||||
<Card.Description className={styles.description}>{dataSourcePlugin.info.description}</Card.Description>
|
||||
|
||||
{/* Signature */}
|
||||
{!isPhantom && (
|
||||
<Card.Meta className={styles.meta}>
|
||||
<PluginSignatureBadge status={dataSourcePlugin.signature} />
|
||||
</Card.Meta>
|
||||
)}
|
||||
|
||||
{/* Learn more */}
|
||||
<Card.Actions className={styles.actions}>
|
||||
{learnMoreLink && (
|
||||
<LinkButton
|
||||
aria-label={`${dataSourcePlugin.name}, learn more.`}
|
||||
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
|
||||
icon="external-link-alt"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
>
|
||||
{learnMoreLink.name}
|
||||
</LinkButton>
|
||||
)}
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
heading: css({
|
||||
fontSize: theme.v1.typography.heading.h5,
|
||||
fontWeight: 'inherit',
|
||||
}),
|
||||
figure: css({
|
||||
width: 'inherit',
|
||||
marginRight: '0px',
|
||||
'> img': {
|
||||
width: theme.spacing(7),
|
||||
},
|
||||
}),
|
||||
meta: css({
|
||||
marginTop: '6px',
|
||||
position: 'relative',
|
||||
}),
|
||||
description: css({
|
||||
margin: '0px',
|
||||
fontSize: theme.typography.size.sm,
|
||||
}),
|
||||
actions: css({
|
||||
position: 'relative',
|
||||
alignSelf: 'center',
|
||||
marginTop: '0px',
|
||||
opacity: 0,
|
||||
|
||||
'.card-parent:hover &, .card-parent:focus-within &': {
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
card: css({
|
||||
gridTemplateAreas: `
|
||||
"Figure Heading Actions"
|
||||
"Figure Description Actions"
|
||||
"Figure Meta Actions"
|
||||
"Figure - Actions"`,
|
||||
}),
|
||||
logo: css({
|
||||
marginRight: theme.v1.spacing.lg,
|
||||
marginLeft: theme.v1.spacing.sm,
|
||||
width: theme.spacing(7),
|
||||
maxHeight: theme.spacing(7),
|
||||
}),
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { DataSourcePluginMeta } from '@grafana/data';
|
||||
import { List } from '@grafana/ui';
|
||||
|
||||
import { DataSourceTypeCard } from './DataSourceTypeCard';
|
||||
|
||||
export type Props = {
|
||||
// The list of data-source plugins to display
|
||||
dataSourcePlugins: DataSourcePluginMeta[];
|
||||
// Called when a data-source plugin is clicked on in the list
|
||||
onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void;
|
||||
};
|
||||
|
||||
export function DataSourceTypeCardList({ dataSourcePlugins, onClickDataSourceType }: Props) {
|
||||
if (!dataSourcePlugins || !dataSourcePlugins.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
items={dataSourcePlugins}
|
||||
getItemKey={(item) => item.id.toString()}
|
||||
renderItem={(item) => <DataSourceTypeCard dataSourcePlugin={item} onClick={() => onClickDataSourceType(item)} />}
|
||||
className={css`
|
||||
> li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
@ -2,40 +2,38 @@ import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { LayoutModes } from '@grafana/data';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { DataSourcesState } from 'app/types';
|
||||
|
||||
import DataSourcesList from './DataSourcesList';
|
||||
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
||||
import { initialState } from './state/reducers';
|
||||
import { getMockDataSources } from '../__mocks__';
|
||||
|
||||
const setup = (stateOverride?: Partial<DataSourcesState>) => {
|
||||
const store = configureStore({
|
||||
dataSources: {
|
||||
...initialState,
|
||||
dataSources: getMockDataSources(3),
|
||||
layoutMode: LayoutModes.Grid,
|
||||
...stateOverride,
|
||||
},
|
||||
});
|
||||
import { DataSourcesListView } from './DataSourcesList';
|
||||
|
||||
const setup = () => {
|
||||
const store = configureStore();
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<DataSourcesList dataSources={getMockDataSources(3)} />
|
||||
<DataSourcesListView
|
||||
dataSources={getMockDataSources(3)}
|
||||
dataSourcesCount={3}
|
||||
isLoading={false}
|
||||
hasCreateRights={true}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('DataSourcesList', () => {
|
||||
describe('<DataSourcesList>', () => {
|
||||
it('should render list of datasources', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
expect(screen.getAllByRole('heading')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should render all elements in the list item', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||
});
|
106
public/app/features/datasources/components/DataSourcesList.tsx
Normal file
106
public/app/features/datasources/components/DataSourcesList.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { Card, Tag, useStyles } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { StoreState, AccessControlAction } from 'app/types';
|
||||
|
||||
import { getDataSources, getDataSourcesCount, useLoadDataSources } from '../state';
|
||||
|
||||
import { DataSourcesListHeader } from './DataSourcesListHeader';
|
||||
|
||||
export function DataSourcesList() {
|
||||
useLoadDataSources();
|
||||
|
||||
const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources));
|
||||
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
||||
const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched);
|
||||
const hasCreateRights = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||
|
||||
return (
|
||||
<DataSourcesListView
|
||||
dataSources={dataSources}
|
||||
dataSourcesCount={dataSourcesCount}
|
||||
isLoading={!hasFetched}
|
||||
hasCreateRights={hasCreateRights}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewProps = {
|
||||
dataSources: DataSourceSettings[];
|
||||
dataSourcesCount: number;
|
||||
isLoading: boolean;
|
||||
hasCreateRights: boolean;
|
||||
};
|
||||
|
||||
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (dataSourcesCount === 0) {
|
||||
return (
|
||||
<EmptyListCTA
|
||||
buttonDisabled={!hasCreateRights}
|
||||
title="No data sources defined"
|
||||
buttonIcon="database"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* List Header */}
|
||||
<DataSourcesListHeader />
|
||||
|
||||
{/* List */}
|
||||
<ul className={styles.list}>
|
||||
{dataSources.map((dataSource) => {
|
||||
return (
|
||||
<li key={dataSource.uid}>
|
||||
<Card href={`datasources/edit/${dataSource.uid}`}>
|
||||
<Card.Heading>{dataSource.name}</Card.Heading>
|
||||
<Card.Figure>
|
||||
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />
|
||||
</Card.Figure>
|
||||
<Card.Meta>
|
||||
{[
|
||||
dataSource.typeName,
|
||||
dataSource.url,
|
||||
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
|
||||
]}
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
list: css({
|
||||
listStyle: 'none',
|
||||
display: 'grid',
|
||||
// gap: '8px', Add back when legacy support for old Card interface is dropped
|
||||
}),
|
||||
logo: css({
|
||||
objectFit: 'contain',
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,19 +1,35 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction, StoreState } from 'app/types';
|
||||
|
||||
import { setDataSourcesSearchQuery } from './state/reducers';
|
||||
import { getDataSourcesSearchQuery } from './state/selectors';
|
||||
import { getDataSourcesSearchQuery, setDataSourcesSearchQuery } from '../state';
|
||||
|
||||
export const DataSourcesListHeader = () => {
|
||||
export function DataSourcesListHeader() {
|
||||
const dispatch = useDispatch();
|
||||
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
|
||||
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
|
||||
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||
|
||||
return (
|
||||
<DataSourcesListHeaderView
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
canCreateDataSource={canCreateDataSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewProps = {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (q: string) => AnyAction;
|
||||
canCreateDataSource: boolean;
|
||||
};
|
||||
|
||||
export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) {
|
||||
const linkButton = {
|
||||
href: 'datasources/new',
|
||||
title: 'Add data source',
|
||||
@ -23,4 +39,4 @@ export const DataSourcesListHeader = () => {
|
||||
return (
|
||||
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} linkButton={linkButton} key="action-bar" />
|
||||
);
|
||||
};
|
||||
}
|
@ -0,0 +1,251 @@
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { PluginState } from '@grafana/data';
|
||||
import { setAngularLoader } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
|
||||
|
||||
import { missingRightsMessage } from './DataSourceMissingRightsMessage';
|
||||
import { readOnlyMessage } from './DataSourceReadOnlyMessage';
|
||||
import { EditDataSourceView, ViewProps } from './EditDataSource';
|
||||
|
||||
const setup = (props?: Partial<ViewProps>) => {
|
||||
const store = configureStore();
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<EditDataSourceView
|
||||
dataSource={getMockDataSource()}
|
||||
dataSourceMeta={getMockDataSourceMeta()}
|
||||
dataSourceSettings={getMockDataSourceSettingsState()}
|
||||
dataSourceRights={{ readOnly: false, hasWriteRights: true, hasDeleteRights: true }}
|
||||
exploreUrl={'/explore'}
|
||||
onDelete={jest.fn()}
|
||||
onDefaultChange={jest.fn()}
|
||||
onNameChange={jest.fn()}
|
||||
onOptionsChange={jest.fn()}
|
||||
onTest={jest.fn()}
|
||||
onUpdate={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<EditDataSource>', () => {
|
||||
beforeAll(() => {
|
||||
setAngularLoader({
|
||||
load: () => ({
|
||||
destroy: jest.fn(),
|
||||
digest: jest.fn(),
|
||||
getScope: () => ({ $watch: () => {} }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('On loading errors', () => {
|
||||
it('should render a Back button', () => {
|
||||
setup({
|
||||
dataSource: getMockDataSource({ name: 'My Datasource' }),
|
||||
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('My Datasource')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Back')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render a Delete button if the user has rights delete the datasource', () => {
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
|
||||
dataSourceRights: {
|
||||
readOnly: false,
|
||||
hasDeleteRights: true,
|
||||
hasWriteRights: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Delete')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not render a Delete button if the user has no rights to delete the datasource', () => {
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
|
||||
dataSourceRights: {
|
||||
readOnly: false,
|
||||
hasDeleteRights: false,
|
||||
hasWriteRights: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a message if the datasource is read-only', () => {
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
|
||||
dataSourceRights: {
|
||||
readOnly: true,
|
||||
hasDeleteRights: false,
|
||||
hasWriteRights: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText(readOnlyMessage)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('On loading', () => {
|
||||
it('should render a loading indicator while the data is being fetched', () => {
|
||||
setup({
|
||||
dataSource: getMockDataSource({ name: 'My Datasource' }),
|
||||
dataSourceSettings: getMockDataSourceSettingsState({ loading: true }),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Loading ...')).toBeVisible();
|
||||
expect(screen.queryByText('My Datasource')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render loading when data is already available', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('On editing', () => {
|
||||
it('should render no messages if the user has write access and if the data-source is not read-only', () => {
|
||||
setup({
|
||||
dataSourceRights: {
|
||||
readOnly: false,
|
||||
hasDeleteRights: true,
|
||||
hasWriteRights: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText(readOnlyMessage)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(missingRightsMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a message if the user has no write access', () => {
|
||||
setup({
|
||||
dataSourceRights: {
|
||||
readOnly: false,
|
||||
hasDeleteRights: false,
|
||||
hasWriteRights: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText(missingRightsMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render a message if the data-source is read-only', () => {
|
||||
setup({
|
||||
dataSourceRights: {
|
||||
readOnly: true,
|
||||
hasDeleteRights: false,
|
||||
hasWriteRights: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText(readOnlyMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render a beta info message if the plugin is still in Beta state', () => {
|
||||
setup({
|
||||
dataSourceMeta: getMockDataSourceMeta({
|
||||
state: PluginState.beta,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render an alpha info message if the plugin is still in Alpha state', () => {
|
||||
setup({
|
||||
dataSourceMeta: getMockDataSourceMeta({
|
||||
state: PluginState.alpha,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTitle('This feature is experimental and future updates might not be backward compatible')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render testing errors with a detailed error message', () => {
|
||||
const message = 'message';
|
||||
const detailsMessage = 'detailed message';
|
||||
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({
|
||||
testingStatus: {
|
||||
message,
|
||||
status: 'error',
|
||||
details: { message: detailsMessage },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText(message)).toBeVisible();
|
||||
expect(screen.getByText(detailsMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render testing errors with empty details', () => {
|
||||
const message = 'message';
|
||||
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({
|
||||
testingStatus: {
|
||||
message,
|
||||
status: 'error',
|
||||
details: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText(message)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render testing errors with no details', () => {
|
||||
const message = 'message';
|
||||
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({
|
||||
testingStatus: {
|
||||
message,
|
||||
status: 'error',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText(message)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should use the verboseMessage property in the error details whenever it is available', () => {
|
||||
const message = 'message';
|
||||
const detailsMessage = 'detailed message';
|
||||
const detailsVerboseMessage = 'even more detailed...';
|
||||
|
||||
setup({
|
||||
dataSourceSettings: getMockDataSourceSettingsState({
|
||||
testingStatus: {
|
||||
message,
|
||||
status: 'error',
|
||||
details: {
|
||||
details: detailsMessage,
|
||||
verboseMessage: detailsVerboseMessage,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.queryByText(message)).toBeVisible();
|
||||
expect(screen.queryByText(detailsMessage)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(detailsVerboseMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
171
public/app/features/datasources/components/EditDataSource.tsx
Normal file
171
public/app/features/datasources/components/EditDataSource.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { DataSourcePluginMeta, DataSourceSettings as DataSourceSettingsType } from '@grafana/data';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { DataSourceSettingsState, ThunkResult } from 'app/types';
|
||||
|
||||
import {
|
||||
dataSourceLoaded,
|
||||
setDataSourceName,
|
||||
setIsDefault,
|
||||
useDataSource,
|
||||
useDataSourceExploreUrl,
|
||||
useDataSourceMeta,
|
||||
useDataSourceRights,
|
||||
useDataSourceSettings,
|
||||
useDeleteLoadedDataSource,
|
||||
useInitDataSourceSettings,
|
||||
useTestDataSource,
|
||||
useUpdateDatasource,
|
||||
} from '../state';
|
||||
import { DataSourceRights } from '../types';
|
||||
|
||||
import { BasicSettings } from './BasicSettings';
|
||||
import { ButtonRow } from './ButtonRow';
|
||||
import { CloudInfoBox } from './CloudInfoBox';
|
||||
import { DataSourceLoadError } from './DataSourceLoadError';
|
||||
import { DataSourceMissingRightsMessage } from './DataSourceMissingRightsMessage';
|
||||
import { DataSourcePluginConfigPage } from './DataSourcePluginConfigPage';
|
||||
import { DataSourcePluginSettings } from './DataSourcePluginSettings';
|
||||
import { DataSourcePluginState } from './DataSourcePluginState';
|
||||
import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage';
|
||||
import { DataSourceTestingStatus } from './DataSourceTestingStatus';
|
||||
|
||||
export type Props = {
|
||||
// The ID of the data source
|
||||
uid: string;
|
||||
// The ID of the custom datasource setting page
|
||||
pageId?: string | null;
|
||||
};
|
||||
|
||||
export function EditDataSource({ uid, pageId }: Props) {
|
||||
useInitDataSourceSettings(uid);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dataSource = useDataSource(uid);
|
||||
const dataSourceMeta = useDataSourceMeta(uid);
|
||||
const dataSourceSettings = useDataSourceSettings();
|
||||
const dataSourceRights = useDataSourceRights(uid);
|
||||
const exploreUrl = useDataSourceExploreUrl(uid);
|
||||
const onDelete = useDeleteLoadedDataSource();
|
||||
const onTest = useTestDataSource(uid);
|
||||
const onUpdate = useUpdateDatasource();
|
||||
const onDefaultChange = (value: boolean) => dispatch(setIsDefault(value));
|
||||
const onNameChange = (name: string) => dispatch(setDataSourceName(name));
|
||||
const onOptionsChange = (ds: DataSourceSettingsType) => dispatch(dataSourceLoaded(ds));
|
||||
|
||||
return (
|
||||
<EditDataSourceView
|
||||
pageId={pageId}
|
||||
dataSource={dataSource}
|
||||
dataSourceMeta={dataSourceMeta}
|
||||
dataSourceSettings={dataSourceSettings}
|
||||
dataSourceRights={dataSourceRights}
|
||||
exploreUrl={exploreUrl}
|
||||
onDelete={onDelete}
|
||||
onDefaultChange={onDefaultChange}
|
||||
onNameChange={onNameChange}
|
||||
onOptionsChange={onOptionsChange}
|
||||
onTest={onTest}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewProps = {
|
||||
pageId?: string | null;
|
||||
dataSource: DataSourceSettingsType;
|
||||
dataSourceMeta: DataSourcePluginMeta;
|
||||
dataSourceSettings: DataSourceSettingsState;
|
||||
dataSourceRights: DataSourceRights;
|
||||
exploreUrl: string;
|
||||
onDelete: () => void;
|
||||
onDefaultChange: (isDefault: boolean) => AnyAction;
|
||||
onNameChange: (name: string) => AnyAction;
|
||||
onOptionsChange: (dataSource: DataSourceSettingsType) => AnyAction;
|
||||
onTest: () => ThunkResult<void>;
|
||||
onUpdate: (dataSource: DataSourceSettingsType) => ThunkResult<void>;
|
||||
};
|
||||
|
||||
export function EditDataSourceView({
|
||||
pageId,
|
||||
dataSource,
|
||||
dataSourceMeta,
|
||||
dataSourceSettings,
|
||||
dataSourceRights,
|
||||
exploreUrl,
|
||||
onDelete,
|
||||
onDefaultChange,
|
||||
onNameChange,
|
||||
onOptionsChange,
|
||||
onTest,
|
||||
onUpdate,
|
||||
}: ViewProps) {
|
||||
const { plugin, loadError, testingStatus, loading } = dataSourceSettings;
|
||||
const { readOnly, hasWriteRights, hasDeleteRights } = dataSourceRights;
|
||||
const hasDataSource = dataSource.id > 0;
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
await onUpdate({ ...dataSource });
|
||||
|
||||
onTest();
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return <DataSourceLoadError dataSourceRights={dataSourceRights} onDelete={onDelete} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
// TODO - is this needed?
|
||||
if (!hasDataSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pageId) {
|
||||
return <DataSourcePluginConfigPage pageId={pageId} plugin={plugin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!hasWriteRights && <DataSourceMissingRightsMessage />}
|
||||
{readOnly && <DataSourceReadOnlyMessage />}
|
||||
{dataSourceMeta.state && <DataSourcePluginState state={dataSourceMeta.state} />}
|
||||
|
||||
<CloudInfoBox dataSource={dataSource} />
|
||||
|
||||
<BasicSettings
|
||||
dataSourceName={dataSource.name}
|
||||
isDefault={dataSource.isDefault}
|
||||
onDefaultChange={onDefaultChange}
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
|
||||
{plugin && (
|
||||
<DataSourcePluginSettings
|
||||
plugin={plugin}
|
||||
dataSource={dataSource}
|
||||
dataSourceMeta={dataSourceMeta}
|
||||
onModelChange={onOptionsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataSourceTestingStatus testingStatus={testingStatus} />
|
||||
|
||||
<ButtonRow
|
||||
onSubmit={onSubmit}
|
||||
onDelete={onDelete}
|
||||
onTest={onTest}
|
||||
exploreUrl={exploreUrl}
|
||||
canSave={!readOnly && hasWriteRights}
|
||||
canDelete={!readOnly && hasDeleteRights}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
89
public/app/features/datasources/components/NewDataSource.tsx
Normal file
89
public/app/features/datasources/components/NewDataSource.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { DataSourcePluginMeta } from '@grafana/data';
|
||||
import { LinkButton, FilterInput } from '@grafana/ui';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { PluginsErrorsInfo } from 'app/features/plugins/components/PluginsErrorsInfo';
|
||||
import { DataSourcePluginCategory, StoreState } from 'app/types';
|
||||
|
||||
import { DataSourceCategories } from '../components/DataSourceCategories';
|
||||
import { DataSourceTypeCardList } from '../components/DataSourceTypeCardList';
|
||||
import {
|
||||
useAddDatasource,
|
||||
useLoadDataSourcePlugins,
|
||||
getFilteredDataSourcePlugins,
|
||||
setDataSourceTypeSearchQuery,
|
||||
} from '../state';
|
||||
|
||||
export function NewDataSource() {
|
||||
useLoadDataSourcePlugins();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const filteredDataSources = useSelector((s: StoreState) => getFilteredDataSourcePlugins(s.dataSources));
|
||||
const searchQuery = useSelector((s: StoreState) => s.dataSources.dataSourceTypeSearchQuery);
|
||||
const isLoading = useSelector((s: StoreState) => s.dataSources.isLoadingDataSources);
|
||||
const dataSourceCategories = useSelector((s: StoreState) => s.dataSources.categories);
|
||||
const onAddDataSource = useAddDatasource();
|
||||
const onSetSearchQuery = (q: string) => dispatch(setDataSourceTypeSearchQuery(q));
|
||||
|
||||
return (
|
||||
<NewDataSourceView
|
||||
dataSources={filteredDataSources}
|
||||
dataSourceCategories={dataSourceCategories}
|
||||
searchQuery={searchQuery}
|
||||
isLoading={isLoading}
|
||||
onAddDataSource={onAddDataSource}
|
||||
onSetSearchQuery={onSetSearchQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewProps = {
|
||||
dataSources: DataSourcePluginMeta[];
|
||||
dataSourceCategories: DataSourcePluginCategory[];
|
||||
searchQuery: string;
|
||||
isLoading: boolean;
|
||||
onAddDataSource: (dataSource: DataSourcePluginMeta) => void;
|
||||
onSetSearchQuery: (q: string) => AnyAction;
|
||||
};
|
||||
|
||||
export function NewDataSourceView({
|
||||
dataSources,
|
||||
dataSourceCategories,
|
||||
searchQuery,
|
||||
isLoading,
|
||||
onAddDataSource,
|
||||
onSetSearchQuery,
|
||||
}: ViewProps) {
|
||||
if (isLoading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="page-action-bar">
|
||||
<FilterInput value={searchQuery} onChange={onSetSearchQuery} placeholder="Filter by name or type" />
|
||||
<div className="page-action-bar__spacer" />
|
||||
<LinkButton href="datasources" fill="outline" variant="secondary" icon="arrow-left">
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
{/* Show any plugin errors while not searching for anything specific */}
|
||||
{!searchQuery && <PluginsErrorsInfo />}
|
||||
|
||||
{/* Search results */}
|
||||
<div>
|
||||
{searchQuery && (
|
||||
<DataSourceTypeCardList dataSourcePlugins={dataSources} onClickDataSourceType={onAddDataSource} />
|
||||
)}
|
||||
{!searchQuery && (
|
||||
<DataSourceCategories categories={dataSourceCategories} onClickDataSourceType={onAddDataSource} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
|
||||
export function createDatasourceSettings<T>(jsonData: T): DataSourceSettings<T> {
|
||||
return {
|
||||
id: 0,
|
||||
uid: 'x',
|
||||
orgId: 0,
|
||||
name: 'datasource-test',
|
||||
typeLogoUrl: '',
|
||||
type: 'datasource',
|
||||
typeName: 'Datasource',
|
||||
access: 'server',
|
||||
url: 'http://localhost',
|
||||
user: '',
|
||||
database: '',
|
||||
basicAuth: false,
|
||||
basicAuthUser: '',
|
||||
isDefault: false,
|
||||
jsonData,
|
||||
readOnly: false,
|
||||
withCredentials: false,
|
||||
secureJsonFields: {},
|
||||
};
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
|
||||
import { setAngularLoader } from '@grafana/runtime';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { navIndex, getMockDataSource } from '../__mocks__';
|
||||
import * as api from '../api';
|
||||
import { initialState as dataSourcesInitialState } from '../state';
|
||||
|
||||
import DataSourceDashboardsPage from './DataSourceDashboardsPage';
|
||||
|
||||
jest.mock('../api');
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
const setup = (uid: string, store: Store) =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<DataSourceDashboardsPage
|
||||
{...getRouteComponentProps({
|
||||
// @ts-ignore
|
||||
match: {
|
||||
params: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe('<DataSourceDashboardsPage>', () => {
|
||||
const uid = 'foo';
|
||||
const dataSourceName = 'My DataSource';
|
||||
const dataSource = getMockDataSource<{}>({ uid, name: dataSourceName });
|
||||
let store: Store;
|
||||
|
||||
beforeAll(() => {
|
||||
setAngularLoader({
|
||||
load: () => ({
|
||||
destroy: jest.fn(),
|
||||
digest: jest.fn(),
|
||||
getScope: () => ({ $watch: () => {} }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource);
|
||||
|
||||
store = configureStore({
|
||||
dataSources: {
|
||||
...dataSourcesInitialState,
|
||||
dataSource: dataSource,
|
||||
},
|
||||
navIndex: {
|
||||
...navIndex,
|
||||
[`datasource-dashboards-${uid}`]: {
|
||||
id: `datasource-dashboards-${uid}`,
|
||||
text: dataSourceName,
|
||||
icon: 'list-ul',
|
||||
url: `/datasources/edit/${uid}/dashboards`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the dashboards page without an issue', () => {
|
||||
setup(uid, store);
|
||||
|
||||
expect(screen.queryByText(dataSourceName)).toBeVisible();
|
||||
});
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { DataSourceDashboards } from '../components/DataSourceDashboards';
|
||||
|
||||
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
export function DataSourceDashboardsPage(props: Props) {
|
||||
const uid = props.match.params.uid;
|
||||
|
||||
return (
|
||||
<Page navId={`datasource-dashboards-${uid}`}>
|
||||
<Page.Contents>
|
||||
<DataSourceDashboards uid={uid} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataSourceDashboardsPage;
|
@ -6,24 +6,14 @@ import { DataSourceSettings, LayoutModes } from '@grafana/data';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { DataSourcesState } from 'app/types';
|
||||
|
||||
import { navIndex, getMockDataSources } from '../__mocks__';
|
||||
import { initialState } from '../state';
|
||||
|
||||
import { DataSourcesListPage } from './DataSourcesListPage';
|
||||
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
|
||||
import navIndex from './__mocks__/store.navIndex.mock';
|
||||
import { initialState } from './state/reducers';
|
||||
|
||||
jest.mock('app/core/core', () => {
|
||||
return {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getMock = jest.fn().mockResolvedValue([]);
|
||||
|
||||
jest.mock('app/core/services/backend_srv', () => ({
|
||||
...jest.requireActual('app/core/services/backend_srv'),
|
||||
getBackendSrv: () => ({ get: getMock }),
|
||||
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }),
|
||||
}));
|
||||
|
||||
const setup = (stateOverride?: Partial<DataSourcesState>) => {
|
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DataSourcesList } from '../components/DataSourcesList';
|
||||
|
||||
export function DataSourcesListPage() {
|
||||
return (
|
||||
<Page navId="datasources">
|
||||
<Page.Contents>
|
||||
<DataSourcesList />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataSourcesListPage;
|
@ -0,0 +1,99 @@
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
|
||||
import { LayoutModes } from '@grafana/data';
|
||||
import { setAngularLoader } from '@grafana/runtime';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { navIndex, getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
|
||||
import * as api from '../api';
|
||||
import { initialState } from '../state';
|
||||
|
||||
import { EditDataSourcePage } from './EditDataSourcePage';
|
||||
|
||||
jest.mock('../api');
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
const setup = (uid: string, store: Store) =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<EditDataSourcePage
|
||||
{...getRouteComponentProps({
|
||||
// @ts-ignore
|
||||
match: {
|
||||
params: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe('<EditDataSourcePage>', () => {
|
||||
const uid = 'foo';
|
||||
const name = 'My DataSource';
|
||||
const dataSource = getMockDataSource<{}>({ uid, name });
|
||||
const dataSourceMeta = getMockDataSourceMeta();
|
||||
const dataSourceSettings = getMockDataSourceSettingsState();
|
||||
let store: Store;
|
||||
|
||||
beforeAll(() => {
|
||||
setAngularLoader({
|
||||
load: () => ({
|
||||
destroy: jest.fn(),
|
||||
digest: jest.fn(),
|
||||
getScope: () => ({ $watch: () => {} }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource);
|
||||
|
||||
store = configureStore({
|
||||
dataSourceSettings,
|
||||
dataSources: {
|
||||
...initialState,
|
||||
dataSources: [dataSource],
|
||||
dataSource: dataSource,
|
||||
dataSourceMeta: dataSourceMeta,
|
||||
layoutMode: LayoutModes.Grid,
|
||||
hasFetched: true,
|
||||
},
|
||||
navIndex: {
|
||||
...navIndex,
|
||||
[`datasource-settings-${uid}`]: {
|
||||
id: `datasource-settings-${uid}`,
|
||||
text: name,
|
||||
icon: 'list-ul',
|
||||
url: `/datasources/edit/${uid}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the edit page without an issue', () => {
|
||||
setup(uid, store);
|
||||
|
||||
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
|
||||
|
||||
// Title
|
||||
expect(screen.queryByText(name)).toBeVisible();
|
||||
|
||||
// Buttons
|
||||
expect(screen.queryByRole('button', { name: /Back/i })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: /Delete/i })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible();
|
||||
expect(screen.queryByText('Explore')).toBeVisible();
|
||||
});
|
||||
});
|
26
public/app/features/datasources/pages/EditDataSourcePage.tsx
Normal file
26
public/app/features/datasources/pages/EditDataSourcePage.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { EditDataSource } from '../components/EditDataSource';
|
||||
import { useDataSourceSettingsNav } from '../state';
|
||||
|
||||
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
export function EditDataSourcePage(props: Props) {
|
||||
const uid = props.match.params.uid;
|
||||
const params = new URLSearchParams(props.location.search);
|
||||
const pageId = params.get('page');
|
||||
const nav = useDataSourceSettingsNav(uid, pageId);
|
||||
|
||||
return (
|
||||
<Page navModel={nav}>
|
||||
<Page.Contents>
|
||||
<EditDataSource uid={uid} pageId={pageId} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditDataSourcePage;
|
35
public/app/features/datasources/pages/NewDataSourcePage.tsx
Normal file
35
public/app/features/datasources/pages/NewDataSourcePage.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { NewDataSource } from '../components/NewDataSource';
|
||||
|
||||
const navModel = getNavModel();
|
||||
|
||||
export function NewDataSourcePage() {
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents>
|
||||
<NewDataSource />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export function getNavModel(): NavModel {
|
||||
const main = {
|
||||
icon: 'database',
|
||||
id: 'datasource-new',
|
||||
text: 'Add data source',
|
||||
href: 'datasources/new',
|
||||
subTitle: 'Choose a data source type',
|
||||
};
|
||||
|
||||
return {
|
||||
main: main,
|
||||
node: main,
|
||||
};
|
||||
}
|
||||
|
||||
export default NewDataSourcePage;
|
@ -1,169 +0,0 @@
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { PluginState } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
|
||||
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
|
||||
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
|
||||
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
|
||||
|
||||
import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage';
|
||||
|
||||
jest.mock('app/core/core', () => {
|
||||
return {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getMockNode = () => ({
|
||||
text: 'text',
|
||||
subTitle: 'subtitle',
|
||||
icon: 'icon',
|
||||
});
|
||||
|
||||
const getProps = (): Props => ({
|
||||
...getRouteComponentProps(),
|
||||
navModel: {
|
||||
node: getMockNode(),
|
||||
main: getMockNode(),
|
||||
},
|
||||
dataSource: getMockDataSource(),
|
||||
dataSourceMeta: getMockPlugin(),
|
||||
dataSourceId: 'x',
|
||||
deleteDataSource: jest.fn(),
|
||||
loadDataSource: jest.fn(),
|
||||
setDataSourceName,
|
||||
updateDataSource: jest.fn(),
|
||||
initDataSourceSettings: jest.fn(),
|
||||
testDataSource: jest.fn(),
|
||||
setIsDefault,
|
||||
dataSourceLoaded,
|
||||
cleanUpAction,
|
||||
page: null,
|
||||
plugin: null,
|
||||
loadError: null,
|
||||
loading: false,
|
||||
testingStatus: {},
|
||||
});
|
||||
|
||||
describe('Render', () => {
|
||||
it('should not render loading when props are ready', () => {
|
||||
render(<DataSourceSettingsPage {...getProps()} />);
|
||||
|
||||
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading if datasource is not ready', () => {
|
||||
const mockProps = getProps();
|
||||
mockProps.dataSource.id = 0;
|
||||
mockProps.loading = true;
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByText('Loading ...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render beta info text if plugin state is beta', () => {
|
||||
const mockProps = getProps();
|
||||
mockProps.dataSourceMeta.state = PluginState.beta;
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render alpha info text if plugin state is alpha', () => {
|
||||
const mockProps = getProps();
|
||||
mockProps.dataSourceMeta.state = PluginState.alpha;
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByTitle('This feature is experimental and future updates might not be backward compatible')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render is ready only message is readOnly is false', () => {
|
||||
const mockProps = getProps();
|
||||
mockProps.dataSource.readOnly = false;
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.queryByLabelText(selectors.pages.DataSource.readOnly)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render is ready only message is readOnly is true', () => {
|
||||
const mockProps = getProps();
|
||||
mockProps.dataSource.readOnly = true;
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByLabelText(selectors.pages.DataSource.readOnly)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error message with detailed message', () => {
|
||||
const mockProps = {
|
||||
...getProps(),
|
||||
testingStatus: {
|
||||
message: 'message',
|
||||
status: 'error',
|
||||
details: { message: 'detailed message' },
|
||||
},
|
||||
};
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProps.testingStatus.details.message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error message with empty details', () => {
|
||||
const mockProps = {
|
||||
...getProps(),
|
||||
testingStatus: {
|
||||
message: 'message',
|
||||
status: 'error',
|
||||
details: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error message without details', () => {
|
||||
const mockProps = {
|
||||
...getProps(),
|
||||
testingStatus: {
|
||||
message: 'message',
|
||||
status: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render verbose error message with detailed verbose error message', () => {
|
||||
const mockProps = {
|
||||
...getProps(),
|
||||
testingStatus: {
|
||||
message: 'message',
|
||||
status: 'error',
|
||||
details: { message: 'detailed message', verboseMessage: 'verbose message' },
|
||||
},
|
||||
};
|
||||
|
||||
render(<DataSourceSettingsPage {...mockProps} />);
|
||||
|
||||
expect(screen.getByText(mockProps.testingStatus.details.verboseMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,306 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { DataSourceSettings, urlUtil } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Alert, Button } from '@grafana/ui';
|
||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
||||
import { StoreState, AccessControlAction } from 'app/types/';
|
||||
|
||||
import { ShowConfirmModalEvent } from '../../../types/events';
|
||||
import {
|
||||
deleteDataSource,
|
||||
initDataSourceSettings,
|
||||
loadDataSource,
|
||||
testDataSource,
|
||||
updateDataSource,
|
||||
} from '../state/actions';
|
||||
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel';
|
||||
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
|
||||
import { getDataSource, getDataSourceMeta } from '../state/selectors';
|
||||
|
||||
import BasicSettings from './BasicSettings';
|
||||
import ButtonRow from './ButtonRow';
|
||||
import { CloudInfoBox } from './CloudInfoBox';
|
||||
import { PluginSettings } from './PluginSettings';
|
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
const dataSourceId = props.match.params.uid;
|
||||
const params = new URLSearchParams(props.location.search);
|
||||
const dataSource = getDataSource(state.dataSources, dataSourceId);
|
||||
const { plugin, loadError, loading, testingStatus } = state.dataSourceSettings;
|
||||
const page = params.get('page');
|
||||
|
||||
const nav = plugin
|
||||
? getDataSourceNav(buildNavModel(dataSource, plugin), page || 'settings')
|
||||
: getDataSourceLoadingNav('settings');
|
||||
|
||||
const navModel = getNavModel(
|
||||
state.navIndex,
|
||||
page ? `datasource-page-${page}` : `datasource-settings-${dataSourceId}`,
|
||||
nav
|
||||
);
|
||||
|
||||
return {
|
||||
dataSource: getDataSource(state.dataSources, dataSourceId),
|
||||
dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type),
|
||||
dataSourceId: dataSourceId,
|
||||
page,
|
||||
plugin,
|
||||
loadError,
|
||||
loading,
|
||||
testingStatus,
|
||||
navModel,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
deleteDataSource,
|
||||
loadDataSource,
|
||||
setDataSourceName,
|
||||
updateDataSource,
|
||||
setIsDefault,
|
||||
dataSourceLoaded,
|
||||
initDataSourceSettings,
|
||||
testDataSource,
|
||||
cleanUpAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
export class DataSourceSettingsPage extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
const { initDataSourceSettings, dataSourceId } = this.props;
|
||||
initDataSourceSettings(dataSourceId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.cleanUpAction({
|
||||
stateSelector: (state) => state.dataSourceSettings,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
await this.props.updateDataSource({ ...this.props.dataSource });
|
||||
|
||||
this.testDataSource();
|
||||
};
|
||||
|
||||
onTest = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
this.testDataSource();
|
||||
};
|
||||
|
||||
onDelete = () => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: 'Delete',
|
||||
text: `Are you sure you want to delete the "${this.props.dataSource.name}" data source?`,
|
||||
yesText: 'Delete',
|
||||
icon: 'trash-alt',
|
||||
onConfirm: () => {
|
||||
this.confirmDelete();
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
confirmDelete = () => {
|
||||
this.props.deleteDataSource();
|
||||
};
|
||||
|
||||
onModelChange = (dataSource: DataSourceSettings) => {
|
||||
this.props.dataSourceLoaded(dataSource);
|
||||
};
|
||||
|
||||
isReadOnly() {
|
||||
return this.props.dataSource.readOnly === true;
|
||||
}
|
||||
|
||||
renderIsReadOnlyMessage() {
|
||||
return (
|
||||
<Alert aria-label={selectors.pages.DataSource.readOnly} severity="info" title="Provisioned data source">
|
||||
This data source was added by config and cannot be modified using the UI. Please contact your server admin to
|
||||
update this data source.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
renderMissingEditRightsMessage() {
|
||||
return (
|
||||
<Alert severity="info" title="Missing rights">
|
||||
You are not allowed to modify this data source. Please contact your server admin to update this data source.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
testDataSource() {
|
||||
const { dataSource, testDataSource } = this.props;
|
||||
testDataSource(dataSource.name);
|
||||
}
|
||||
|
||||
get hasDataSource() {
|
||||
return this.props.dataSource.id > 0;
|
||||
}
|
||||
|
||||
onNavigateToExplore() {
|
||||
const { dataSource } = this.props;
|
||||
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
|
||||
const url = urlUtil.renderUrl('/explore', { left: exploreState });
|
||||
return url;
|
||||
}
|
||||
|
||||
renderLoadError() {
|
||||
const { loadError, dataSource } = this.props;
|
||||
const canDeleteDataSource =
|
||||
!this.isReadOnly() && contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
|
||||
|
||||
const node = {
|
||||
text: loadError!,
|
||||
subTitle: 'Data Source Error',
|
||||
icon: 'exclamation-triangle',
|
||||
};
|
||||
const nav = {
|
||||
node: node,
|
||||
main: node,
|
||||
};
|
||||
|
||||
return (
|
||||
<Page navModel={nav}>
|
||||
<Page.Contents isLoading={this.props.loading}>
|
||||
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
|
||||
<div className="gf-form-button-row">
|
||||
{canDeleteDataSource && (
|
||||
<Button type="submit" variant="destructive" onClick={this.onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" fill="outline" type="button" onClick={() => history.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
renderConfigPageBody(page: string) {
|
||||
const { plugin } = this.props;
|
||||
if (!plugin || !plugin.configPages) {
|
||||
return null; // still loading
|
||||
}
|
||||
|
||||
for (const p of plugin.configPages) {
|
||||
if (p.id === page) {
|
||||
// Investigate is any plugins using this? We should change this interface
|
||||
return <p.body plugin={plugin} query={{}} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <div>Page not found: {page}</div>;
|
||||
}
|
||||
|
||||
renderAlertDetails() {
|
||||
const { testingStatus } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{testingStatus?.details?.message}
|
||||
{testingStatus?.details?.verboseMessage ? (
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>{testingStatus?.details?.verboseMessage}</details>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props;
|
||||
const canWriteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource);
|
||||
const canDeleteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
|
||||
|
||||
return (
|
||||
<form onSubmit={this.onSubmit}>
|
||||
{!canWriteDataSource && this.renderMissingEditRightsMessage()}
|
||||
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
|
||||
{dataSourceMeta.state && (
|
||||
<div className="gf-form">
|
||||
<label className="gf-form-label width-10">Plugin state</label>
|
||||
<label className="gf-form-label gf-form-label--transparent">
|
||||
<PluginStateInfo state={dataSourceMeta.state} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CloudInfoBox dataSource={dataSource} />
|
||||
|
||||
<BasicSettings
|
||||
dataSourceName={dataSource.name}
|
||||
isDefault={dataSource.isDefault}
|
||||
onDefaultChange={(state) => setIsDefault(state)}
|
||||
onNameChange={(name) => setDataSourceName(name)}
|
||||
/>
|
||||
|
||||
{plugin && (
|
||||
<PluginSettings
|
||||
plugin={plugin}
|
||||
dataSource={dataSource}
|
||||
dataSourceMeta={dataSourceMeta}
|
||||
onModelChange={this.onModelChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{testingStatus?.message && (
|
||||
<div className="gf-form-group p-t-2">
|
||||
<Alert
|
||||
severity={testingStatus.status === 'error' ? 'error' : 'success'}
|
||||
title={testingStatus.message}
|
||||
aria-label={selectors.pages.DataSource.alert}
|
||||
>
|
||||
{testingStatus.details && this.renderAlertDetails()}
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ButtonRow
|
||||
onSubmit={(event) => this.onSubmit(event)}
|
||||
canSave={!this.isReadOnly() && canWriteDataSource}
|
||||
canDelete={!this.isReadOnly() && canDeleteDataSource}
|
||||
onDelete={this.onDelete}
|
||||
onTest={(event) => this.onTest(event)}
|
||||
exploreUrl={this.onNavigateToExplore()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel, page, loadError, loading } = this.props;
|
||||
|
||||
if (loadError) {
|
||||
return this.renderLoadError();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={loading}>
|
||||
{this.hasDataSource ? <div>{page ? this.renderConfigPageBody(page) : this.renderSettings()}</div> : null}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connector(DataSourceSettingsPage);
|
@ -1,21 +1,19 @@
|
||||
import { of } from 'rxjs';
|
||||
import { thunkTester } from 'test/core/thunk/thunkTester';
|
||||
|
||||
import { BackendSrvRequest, FetchError, FetchResponse } from '@grafana/runtime';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { FetchError } from '@grafana/runtime';
|
||||
import { ThunkResult, ThunkDispatch } from 'app/types';
|
||||
|
||||
import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks';
|
||||
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
|
||||
import { initDataSourceSettings } from '../state/actions';
|
||||
import { getMockDataSource } from '../__mocks__';
|
||||
import * as api from '../api';
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
import {
|
||||
findNewName,
|
||||
nameExits,
|
||||
InitDataSourceSettingDependencies,
|
||||
testDataSource,
|
||||
TestDataSourceDependencies,
|
||||
getDataSourceUsingUidOrId,
|
||||
initDataSourceSettings,
|
||||
loadDataSource,
|
||||
} from './actions';
|
||||
import {
|
||||
initDataSourceSettingsSucceeded,
|
||||
@ -23,8 +21,10 @@ import {
|
||||
testDataSourceStarting,
|
||||
testDataSourceSucceeded,
|
||||
testDataSourceFailed,
|
||||
dataSourceLoaded,
|
||||
} from './reducers';
|
||||
|
||||
jest.mock('../api');
|
||||
jest.mock('app/core/services/backend_srv');
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...(jest.requireActual('@grafana/runtime') as unknown as object),
|
||||
@ -67,118 +67,59 @@ const failDataSourceTest = async (error: object) => {
|
||||
return dispatchedActions;
|
||||
};
|
||||
|
||||
describe('getDataSourceUsingUidOrId', () => {
|
||||
const uidResponse = {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 111,
|
||||
uid: 'abcdefg',
|
||||
},
|
||||
};
|
||||
describe('loadDataSource()', () => {
|
||||
it('should resolve to a data-source if a UID was used for fetching', async () => {
|
||||
const dataSourceMock = getMockDataSource();
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
|
||||
const idResponse = {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 222,
|
||||
uid: 'xyz',
|
||||
},
|
||||
};
|
||||
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
|
||||
|
||||
it('should return UID response data', async () => {
|
||||
(getBackendSrv as jest.Mock).mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(uidResponse as FetchResponse);
|
||||
},
|
||||
});
|
||||
const dataSource = await loadDataSource(dataSourceMock.uid)(dispatch, getState, undefined);
|
||||
|
||||
expect(await getDataSourceUsingUidOrId('abcdefg')).toBe(uidResponse.data);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded(dataSource));
|
||||
expect(dataSource).toBe(dataSourceMock);
|
||||
});
|
||||
|
||||
it('should return ID response data', async () => {
|
||||
const uidResponse = {
|
||||
ok: false,
|
||||
};
|
||||
it('should resolve to an empty data-source if an ID (deprecated) was used for fetching', async () => {
|
||||
const id = 123;
|
||||
const uid = 'uid';
|
||||
const dataSourceMock = getMockDataSource({ id, uid });
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
|
||||
(getBackendSrv as jest.Mock)
|
||||
.mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(uidResponse as FetchResponse);
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(idResponse as FetchResponse);
|
||||
},
|
||||
});
|
||||
|
||||
expect(await getDataSourceUsingUidOrId(222)).toBe(idResponse.data);
|
||||
});
|
||||
|
||||
it('should return empty response data', async () => {
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
window.location = {} as Location;
|
||||
|
||||
const uidResponse = {
|
||||
ok: false,
|
||||
};
|
||||
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
|
||||
|
||||
(getBackendSrv as jest.Mock)
|
||||
.mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(uidResponse as FetchResponse);
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(idResponse as FetchResponse);
|
||||
},
|
||||
});
|
||||
// Fetch the datasource by ID
|
||||
const dataSource = await loadDataSource(id.toString())(dispatch, getState, undefined);
|
||||
|
||||
expect(await getDataSourceUsingUidOrId('222')).toStrictEqual({});
|
||||
expect(window.location.href).toBe('/datasources/edit/xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Name exists', () => {
|
||||
const plugins = getMockPlugins(5);
|
||||
|
||||
it('should be true', () => {
|
||||
const name = 'pretty cool plugin-1';
|
||||
|
||||
expect(nameExits(plugins, name)).toEqual(true);
|
||||
expect(dataSource).toEqual({});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded({} as DataSourceSettings));
|
||||
});
|
||||
|
||||
it('should be false', () => {
|
||||
const name = 'pretty cool plugin-6';
|
||||
it('should redirect to a URL which uses the UID if an ID (deprecated) was used for fetching', async () => {
|
||||
const id = 123;
|
||||
const uid = 'uid';
|
||||
const dataSourceMock = getMockDataSource({ id, uid });
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
|
||||
expect(nameExits(plugins, name));
|
||||
});
|
||||
});
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
window.location = {} as Location;
|
||||
|
||||
describe('Find new name', () => {
|
||||
it('should create a new name', () => {
|
||||
const plugins = getMockPlugins(5);
|
||||
const name = 'pretty cool plugin-1';
|
||||
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
|
||||
|
||||
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
|
||||
});
|
||||
// Fetch the datasource by ID
|
||||
await loadDataSource(id.toString())(dispatch, getState, undefined);
|
||||
|
||||
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-');
|
||||
expect(window.location.href).toBe(`/datasources/edit/${uid}`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -187,7 +128,7 @@ describe('initDataSourceSettings', () => {
|
||||
it('then initDataSourceSettingsFailed should be dispatched', async () => {
|
||||
const dispatchedActions = await thunkTester({}).givenThunk(initDataSourceSettings).whenThunkIsDispatched('');
|
||||
|
||||
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]);
|
||||
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid UID'))]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { DataSourcePluginMeta, DataSourceSettings, locationUtil } from '@grafana/data';
|
||||
import {
|
||||
DataSourceWithBackend,
|
||||
@ -10,14 +8,15 @@ import {
|
||||
locationService,
|
||||
} from '@grafana/runtime';
|
||||
import { updateNavIndex } from 'app/core/actions';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
|
||||
import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types';
|
||||
|
||||
import { contextSrv } from '../../../core/services/context_srv';
|
||||
import * as api from '../api';
|
||||
import { nameExits, findNewName } from '../utils';
|
||||
|
||||
import { buildCategories } from './buildCategories';
|
||||
import { buildNavModel } from './navModel';
|
||||
@ -54,7 +53,7 @@ export interface TestDataSourceDependencies {
|
||||
}
|
||||
|
||||
export const initDataSourceSettings = (
|
||||
pageId: string,
|
||||
uid: string,
|
||||
dependencies: InitDataSourceSettingDependencies = {
|
||||
loadDataSource,
|
||||
loadDataSourceMeta,
|
||||
@ -64,21 +63,16 @@ export const initDataSourceSettings = (
|
||||
}
|
||||
): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
if (!pageId) {
|
||||
dispatch(initDataSourceSettingsFailed(new Error('Invalid ID')));
|
||||
if (!uid) {
|
||||
dispatch(initDataSourceSettingsFailed(new Error('Invalid UID')));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const loadedDataSource = await dispatch(dependencies.loadDataSource(pageId));
|
||||
const loadedDataSource = await dispatch(dependencies.loadDataSource(uid));
|
||||
await dispatch(dependencies.loadDataSourceMeta(loadedDataSource));
|
||||
|
||||
// have we already loaded the plugin then we can skip the steps below?
|
||||
if (getState().dataSourceSettings.plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataSource = dependencies.getDataSource(getState().dataSources, pageId);
|
||||
const dataSource = dependencies.getDataSource(getState().dataSources, uid);
|
||||
const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource!.type);
|
||||
const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta);
|
||||
|
||||
@ -133,16 +127,32 @@ export const testDataSource = (
|
||||
|
||||
export function loadDataSources(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
const response = await getBackendSrv().get('/api/datasources');
|
||||
const response = await api.getDataSources();
|
||||
dispatch(dataSourcesLoaded(response));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadDataSource(uid: string): ThunkResult<Promise<DataSourceSettings>> {
|
||||
return async (dispatch) => {
|
||||
const dataSource = await getDataSourceUsingUidOrId(uid);
|
||||
let dataSource = await api.getDataSourceByIdOrUid(uid);
|
||||
|
||||
// Reload route to use UID instead
|
||||
// -------------------------------
|
||||
// In case we were trying to fetch and reference a data-source with an old numeric ID
|
||||
// (which can happen by referencing it with a "old" URL), we would like to automatically redirect
|
||||
// to the new URL format using the UID.
|
||||
// [Please revalidate the following]: Unfortunately we can update the location using react router, but need to fully reload the
|
||||
// route as the nav model page index is not matching with the url in that case.
|
||||
// And react router has no way to unmount remount a route.
|
||||
if (uid !== dataSource.uid) {
|
||||
window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${dataSource.uid}`);
|
||||
|
||||
// Avoid a flashing error while the reload happens
|
||||
dataSource = {} as DataSourceSettings;
|
||||
}
|
||||
|
||||
dispatch(dataSourceLoaded(dataSource));
|
||||
|
||||
return dataSource;
|
||||
};
|
||||
}
|
||||
@ -164,80 +174,26 @@ export function loadDataSourceMeta(dataSource: DataSourceSettings): ThunkResult<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data source by uid or id, if old id detected handles redirect
|
||||
*/
|
||||
export async function getDataSourceUsingUidOrId(uid: string | number): Promise<DataSourceSettings> {
|
||||
// Try first with uid api
|
||||
try {
|
||||
const byUid = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
method: 'GET',
|
||||
url: `/api/datasources/uid/${uid}`,
|
||||
params: accessControlQueryParam(),
|
||||
showErrorAlert: false,
|
||||
})
|
||||
);
|
||||
|
||||
if (byUid.ok) {
|
||||
return byUid.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to lookup data source by uid', err);
|
||||
}
|
||||
|
||||
// try lookup by old db id
|
||||
const id = typeof uid === 'string' ? parseInt(uid, 10) : uid;
|
||||
if (!Number.isNaN(id)) {
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
method: 'GET',
|
||||
url: `/api/datasources/${id}`,
|
||||
params: accessControlQueryParam(),
|
||||
showErrorAlert: false,
|
||||
})
|
||||
);
|
||||
|
||||
// If the uid is a number, then this is a refresh on one of the settings tabs
|
||||
// and we can return the response data
|
||||
if (response.ok && typeof uid === 'number' && response.data.id === uid) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Not ideal to do a full page reload here but so tricky to handle this
|
||||
// otherwise We can update the location using react router, but need to
|
||||
// fully reload the route as the nav model page index is not matching with
|
||||
// the url in that case. And react router has no way to unmount remount a
|
||||
// route
|
||||
if (response.ok && response.data.id.toString() === uid) {
|
||||
window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${response.data.uid}`);
|
||||
return {} as DataSourceSettings; // avoids flashing an error
|
||||
}
|
||||
}
|
||||
|
||||
throw Error('Could not find data source');
|
||||
}
|
||||
|
||||
export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
await dispatch(loadDataSources());
|
||||
|
||||
const dataSources = getStore().dataSources.dataSources;
|
||||
|
||||
const isFirstDataSource = dataSources.length === 0;
|
||||
const newInstance = {
|
||||
name: plugin.name,
|
||||
type: plugin.id,
|
||||
access: 'proxy',
|
||||
isDefault: dataSources.length === 0,
|
||||
isDefault: isFirstDataSource,
|
||||
};
|
||||
|
||||
if (nameExits(dataSources, newInstance.name)) {
|
||||
newInstance.name = findNewName(dataSources, newInstance.name);
|
||||
}
|
||||
|
||||
const result = await getBackendSrv().post('/api/datasources', newInstance);
|
||||
await getDatasourceSrv().reload();
|
||||
const result = await api.createDataSource(newInstance);
|
||||
|
||||
await getDatasourceSrv().reload();
|
||||
await contextSrv.fetchUserPermissions();
|
||||
|
||||
locationService.push(`/datasources/edit/${result.datasource.uid}`);
|
||||
@ -247,7 +203,7 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
|
||||
export function loadDataSourcePlugins(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(dataSourcePluginsLoad());
|
||||
const plugins = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
|
||||
const plugins = await api.getDataSourcePlugins();
|
||||
const categories = buildCategories(plugins);
|
||||
dispatch(dataSourcePluginsLoaded({ plugins, categories }));
|
||||
};
|
||||
@ -255,67 +211,19 @@ export function loadDataSourcePlugins(): ThunkResult<void> {
|
||||
|
||||
export function updateDataSource(dataSource: DataSourceSettings): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); // by UID not yet supported
|
||||
await api.updateDataSource(dataSource);
|
||||
await getDatasourceSrv().reload();
|
||||
return dispatch(loadDataSource(dataSource.uid));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteDataSource(): ThunkResult<void> {
|
||||
export function deleteLoadedDataSource(): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
const dataSource = getStore().dataSources.dataSource;
|
||||
const { uid } = getStore().dataSources.dataSource;
|
||||
|
||||
await getBackendSrv().delete(`/api/datasources/${dataSource.id}`);
|
||||
await api.deleteDataSource(uid);
|
||||
await getDatasourceSrv().reload();
|
||||
|
||||
locationService.push('/datasources');
|
||||
};
|
||||
}
|
||||
|
||||
interface ItemWithName {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function nameExits(dataSources: ItemWithName[], name: string) {
|
||||
return (
|
||||
dataSources.filter((dataSource) => {
|
||||
return dataSource.name.toLowerCase() === name.toLowerCase();
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function findNewName(dataSources: ItemWithName[], name: string) {
|
||||
// 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: string) {
|
||||
return name.endsWith('-', name.length - 1);
|
||||
}
|
||||
|
||||
function getLastDigit(name: string) {
|
||||
return parseInt(name.slice(-1), 10);
|
||||
}
|
||||
|
||||
function incrementLastDigit(digit: number) {
|
||||
return isNaN(digit) ? 1 : digit + 1;
|
||||
}
|
||||
|
||||
function getNewName(name: string) {
|
||||
return name.slice(0, name.length - 1);
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { DataSourcePluginMeta } from '@grafana/data';
|
||||
|
||||
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
|
||||
import { getMockPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
|
||||
|
||||
import { buildCategories } from './buildCategories';
|
||||
|
||||
|
161
public/app/features/datasources/state/hooks.ts
Normal file
161
public/app/features/datasources/state/hooks.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { DataSourcePluginMeta, DataSourceSettings, urlUtil } from '@grafana/data';
|
||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { AccessControlAction, StoreState } from 'app/types';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import { DataSourceRights } from '../types';
|
||||
|
||||
import {
|
||||
initDataSourceSettings,
|
||||
testDataSource,
|
||||
loadDataSource,
|
||||
loadDataSources,
|
||||
loadDataSourcePlugins,
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteLoadedDataSource,
|
||||
} from './actions';
|
||||
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from './navModel';
|
||||
import { getDataSource, getDataSourceMeta } from './selectors';
|
||||
|
||||
export const useInitDataSourceSettings = (uid: string) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(initDataSourceSettings(uid));
|
||||
|
||||
return function cleanUp() {
|
||||
dispatch(
|
||||
cleanUpAction({
|
||||
stateSelector: (state) => state.dataSourceSettings,
|
||||
})
|
||||
);
|
||||
};
|
||||
}, [uid, dispatch]);
|
||||
};
|
||||
|
||||
export const useTestDataSource = (uid: string) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return () => dispatch(testDataSource(uid));
|
||||
};
|
||||
|
||||
export const useLoadDataSources = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadDataSources());
|
||||
}, [dispatch]);
|
||||
};
|
||||
|
||||
export const useLoadDataSource = (uid: string) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadDataSource(uid));
|
||||
}, [dispatch, uid]);
|
||||
};
|
||||
|
||||
export const useLoadDataSourcePlugins = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadDataSourcePlugins());
|
||||
}, [dispatch]);
|
||||
};
|
||||
|
||||
export const useAddDatasource = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (plugin: DataSourcePluginMeta) => {
|
||||
dispatch(addDataSource(plugin));
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateDatasource = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (dataSource: DataSourceSettings) => dispatch(updateDataSource(dataSource));
|
||||
};
|
||||
|
||||
export const useDeleteLoadedDataSource = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { name } = useSelector((state: StoreState) => state.dataSources.dataSource);
|
||||
|
||||
return () => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: 'Delete',
|
||||
text: `Are you sure you want to delete the "${name}" data source?`,
|
||||
yesText: 'Delete',
|
||||
icon: 'trash-alt',
|
||||
onConfirm: () => dispatch(deleteLoadedDataSource()),
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const useDataSource = (uid: string) => {
|
||||
return useSelector((state: StoreState) => getDataSource(state.dataSources, uid));
|
||||
};
|
||||
|
||||
export const useDataSourceExploreUrl = (uid: string) => {
|
||||
const dataSource = useDataSource(uid);
|
||||
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
|
||||
const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState });
|
||||
|
||||
return exploreUrl;
|
||||
};
|
||||
|
||||
export const useDataSourceMeta = (uid: string): DataSourcePluginMeta => {
|
||||
return useSelector((state: StoreState) => getDataSourceMeta(state.dataSources, uid));
|
||||
};
|
||||
|
||||
export const useDataSourceSettings = () => {
|
||||
return useSelector((state: StoreState) => state.dataSourceSettings);
|
||||
};
|
||||
|
||||
export const useDataSourceSettingsNav = (dataSourceId: string, pageId: string | null) => {
|
||||
const dataSource = useDataSource(dataSourceId);
|
||||
const { plugin, loadError, loading } = useDataSourceSettings();
|
||||
const navIndex = useSelector((state: StoreState) => state.navIndex);
|
||||
const navIndexId = pageId ? `datasource-page-${pageId}` : `datasource-settings-${dataSourceId}`;
|
||||
|
||||
if (loadError) {
|
||||
const node = {
|
||||
text: loadError,
|
||||
subTitle: 'Data Source Error',
|
||||
icon: 'exclamation-triangle',
|
||||
};
|
||||
|
||||
return {
|
||||
node: node,
|
||||
main: node,
|
||||
};
|
||||
}
|
||||
|
||||
if (loading || !plugin) {
|
||||
return getNavModel(navIndex, navIndexId, getDataSourceLoadingNav('settings'));
|
||||
}
|
||||
|
||||
return getNavModel(navIndex, navIndexId, getDataSourceNav(buildNavModel(dataSource, plugin), pageId || 'settings'));
|
||||
};
|
||||
|
||||
export const useDataSourceRights = (uid: string): DataSourceRights => {
|
||||
const dataSource = useDataSource(uid);
|
||||
const readOnly = dataSource.readOnly === true;
|
||||
const hasWriteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource);
|
||||
const hasDeleteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
hasWriteRights,
|
||||
hasDeleteRights,
|
||||
};
|
||||
};
|
6
public/app/features/datasources/state/index.ts
Normal file
6
public/app/features/datasources/state/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './actions';
|
||||
export * from './buildCategories';
|
||||
export * from './hooks';
|
||||
export * from './navModel';
|
||||
export * from './reducers';
|
||||
export * from './selectors';
|
@ -3,10 +3,10 @@ import { featureEnabled } from '@grafana/runtime';
|
||||
import { ProBadge } from 'app/core/components/Upgrade/ProBadge';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { highlightTrial } from 'app/features/admin/utils';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { highlightTrial } from '../../admin/utils';
|
||||
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
const loadingDSType = 'Loading';
|
||||
|
||||
|
@ -3,8 +3,8 @@ import { reducerTester } from 'test/core/redux/reducerTester';
|
||||
import { PluginMeta, PluginMetaInfo, PluginType, LayoutModes } from '@grafana/data';
|
||||
import { DataSourceSettingsState, DataSourcesState } from 'app/types';
|
||||
|
||||
import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks';
|
||||
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
|
||||
import { getMockDataSource, getMockDataSources } from '../__mocks__';
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
import {
|
||||
dataSourceLoaded,
|
||||
@ -53,7 +53,7 @@ describe('dataSourcesReducer', () => {
|
||||
|
||||
describe('when dataSourceLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const dataSource = getMockDataSource();
|
||||
const dataSource = getMockDataSource<{}>();
|
||||
|
||||
reducerTester<DataSourcesState>()
|
||||
.givenReducer(dataSourcesReducer, initialState)
|
||||
|
@ -3,7 +3,7 @@ import { AnyAction, createAction } from '@reduxjs/toolkit';
|
||||
import { DataSourcePluginMeta, DataSourceSettings, LayoutMode, LayoutModes } from '@grafana/data';
|
||||
import { DataSourcesState, DataSourceSettingsState, TestingStatus } from 'app/types';
|
||||
|
||||
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
|
||||
import { GenericDataSourcePlugin } from '../types';
|
||||
|
||||
import { DataSourceTypesLoadedPayload } from './actions';
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data';
|
||||
|
||||
import { DataSourcesState } from '../../../types/datasources';
|
||||
import { DataSourcesState } from 'app/types/datasources';
|
||||
|
||||
export const getDataSources = (state: DataSourcesState) => {
|
||||
const regex = new RegExp(state.searchQuery, 'i');
|
||||
@ -10,7 +9,7 @@ export const getDataSources = (state: DataSourcesState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getDataSourcePlugins = (state: DataSourcesState) => {
|
||||
export const getFilteredDataSourcePlugins = (state: DataSourcesState) => {
|
||||
const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i');
|
||||
|
||||
return state.plugins.filter((type: DataSourcePluginMeta) => {
|
||||
|
9
public/app/features/datasources/types.ts
Normal file
9
public/app/features/datasources/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { DataQuery, DataSourceApi, DataSourceJsonData, DataSourcePlugin } from '@grafana/data';
|
||||
|
||||
export type GenericDataSourcePlugin = DataSourcePlugin<DataSourceApi<DataQuery, DataSourceJsonData>>;
|
||||
|
||||
export type DataSourceRights = {
|
||||
readOnly: boolean;
|
||||
hasWriteRights: boolean;
|
||||
hasDeleteRights: boolean;
|
||||
};
|
41
public/app/features/datasources/utils.test.ts
Normal file
41
public/app/features/datasources/utils.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { getMockPlugin, getMockPlugins } from 'app/features/plugins/__mocks__/pluginMocks';
|
||||
|
||||
import { nameExits, findNewName } from './utils';
|
||||
|
||||
describe('Datasources / Utils', () => {
|
||||
describe('nameExists()', () => {
|
||||
const plugins = getMockPlugins(5);
|
||||
|
||||
it('should return TRUE if an existing plugin already has the same name', () => {
|
||||
expect(nameExits(plugins, plugins[1].name)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return FALSE if no plugin has the same name yet', () => {
|
||||
expect(nameExits(plugins, 'unknown-plugin'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNewName()', () => {
|
||||
it('should return with a new name in case an existing plugin already has the same name', () => {
|
||||
const plugins = getMockPlugins(5);
|
||||
const name = 'pretty cool plugin-1';
|
||||
|
||||
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
|
||||
});
|
||||
|
||||
it('should handle names without suffixes when name already exists', () => {
|
||||
const name = 'prometheus';
|
||||
const plugin = getMockPlugin({ name });
|
||||
|
||||
expect(findNewName([plugin], name)).toEqual('prometheus-1');
|
||||
});
|
||||
|
||||
it('should handle names that end with a "-" when name does not exist yet', () => {
|
||||
const plugin = getMockPlugin();
|
||||
const plugins = [plugin];
|
||||
const name = 'pretty cool plugin-';
|
||||
|
||||
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
|
||||
});
|
||||
});
|
||||
});
|
47
public/app/features/datasources/utils.ts
Normal file
47
public/app/features/datasources/utils.ts
Normal file
@ -0,0 +1,47 @@
|
||||
interface ItemWithName {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function nameExits(dataSources: ItemWithName[], name: string) {
|
||||
return (
|
||||
dataSources.filter((dataSource) => {
|
||||
return dataSource.name.toLowerCase() === name.toLowerCase();
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function findNewName(dataSources: ItemWithName[], name: string) {
|
||||
// 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: string) {
|
||||
return name.endsWith('-', name.length - 1);
|
||||
}
|
||||
|
||||
function getLastDigit(name: string) {
|
||||
return parseInt(name.slice(-1), 10);
|
||||
}
|
||||
|
||||
function incrementLastDigit(digit: number) {
|
||||
return isNaN(digit) ? 1 : digit + 1;
|
||||
}
|
||||
|
||||
function getNewName(name: string) {
|
||||
return name.slice(0, name.length - 1);
|
||||
}
|
@ -4,7 +4,7 @@ import React, { PureComponent } from 'react';
|
||||
import { AppEvents, PluginMeta, DataSourceApi } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import DashboardsTable from 'app/features/datasources/DashboardsTable';
|
||||
import DashboardsTable from 'app/features/datasources/components/DashboardsTable';
|
||||
import { PluginDashboard } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
|
@ -22,25 +22,27 @@ const getOriginalActionType = (type: string) => {
|
||||
return type.substring(0, separator);
|
||||
};
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
items: pluginsAdapter.getInitialState(),
|
||||
requests: {},
|
||||
settings: {
|
||||
displayMode: PluginListDisplayMode.Grid,
|
||||
},
|
||||
// Backwards compatibility
|
||||
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
|
||||
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
|
||||
plugins: [],
|
||||
errors: [],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
dashboards: [],
|
||||
isLoadingPluginDashboards: false,
|
||||
panels: {},
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'plugins',
|
||||
initialState: {
|
||||
items: pluginsAdapter.getInitialState(),
|
||||
requests: {},
|
||||
settings: {
|
||||
displayMode: PluginListDisplayMode.Grid,
|
||||
},
|
||||
// Backwards compatibility
|
||||
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
|
||||
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
|
||||
plugins: [],
|
||||
errors: [],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
dashboards: [],
|
||||
isLoadingPluginDashboards: false,
|
||||
panels: {},
|
||||
} as ReducerState,
|
||||
initialState,
|
||||
reducers: {
|
||||
setDisplayMode(state, action: PayloadAction<PluginListDisplayMode>) {
|
||||
state.settings.displayMode = action.payload;
|
||||
|
@ -28,7 +28,7 @@ import * as flatten from 'app/core/utils/flatten';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import * as ticks from 'app/core/utils/ticks';
|
||||
|
||||
import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings';
|
||||
import { GenericDataSourcePlugin } from '../datasources/types';
|
||||
|
||||
import builtInPlugins from './built_in_plugins';
|
||||
import { locateWithCache, registerPluginInCache } from './pluginCacheBuster';
|
||||
|
@ -11,11 +11,10 @@ import { Props, TeamList } from './TeamList';
|
||||
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
|
||||
import { setSearchQuery, setTeamsSearchPage } from './state/reducers';
|
||||
|
||||
jest.mock('app/core/config', () => {
|
||||
return {
|
||||
featureToggles: { accesscontrol: false },
|
||||
};
|
||||
});
|
||||
jest.mock('app/core/config', () => ({
|
||||
...jest.requireActual('app/core/config'),
|
||||
featureToggles: { accesscontrol: false },
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
|
@ -1,19 +1,21 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { getMockDataSource } from 'app/features/datasources/__mocks__';
|
||||
|
||||
import { createDatasourceSettings } from '../../../../features/datasources/mocks';
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
|
||||
export function createDefaultConfigOptions(
|
||||
options?: Partial<ElasticsearchOptions>
|
||||
): DataSourceSettings<ElasticsearchOptions> {
|
||||
return createDatasourceSettings<ElasticsearchOptions>({
|
||||
timeField: '@time',
|
||||
esVersion: '7.0.0',
|
||||
interval: 'Hourly',
|
||||
timeInterval: '10s',
|
||||
maxConcurrentShardRequests: 300,
|
||||
logMessageField: 'test.message',
|
||||
logLevelField: 'test.level',
|
||||
...options,
|
||||
return getMockDataSource<ElasticsearchOptions>({
|
||||
jsonData: {
|
||||
timeField: '@time',
|
||||
esVersion: '7.0.0',
|
||||
interval: 'Hourly',
|
||||
timeInterval: '10s',
|
||||
maxConcurrentShardRequests: 300,
|
||||
logMessageField: 'test.message',
|
||||
logLevelField: 'test.level',
|
||||
...options,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
|
||||
import { createDatasourceSettings } from '../../../features/datasources/mocks';
|
||||
import { getMockDataSource } from '../../../features/datasources/__mocks__';
|
||||
|
||||
import { LokiDatasource } from './datasource';
|
||||
import { LokiOptions } from './types';
|
||||
@ -51,7 +51,7 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF
|
||||
}
|
||||
|
||||
export function createDefaultConfigOptions(): DataSourceSettings<LokiOptions> {
|
||||
return createDatasourceSettings<LokiOptions>({
|
||||
maxLines: '531',
|
||||
return getMockDataSource<LokiOptions>({
|
||||
jsonData: { maxLines: '531' },
|
||||
});
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
|
||||
import { createDatasourceSettings } from '../../../../features/datasources/mocks';
|
||||
import { getMockDataSource } from '../../../../features/datasources/__mocks__';
|
||||
import { PromOptions } from '../types';
|
||||
|
||||
export function createDefaultConfigOptions(): DataSourceSettings<PromOptions> {
|
||||
return createDatasourceSettings<PromOptions>({
|
||||
timeInterval: '1m',
|
||||
queryTimeout: '1m',
|
||||
httpMethod: 'GET',
|
||||
directUrl: 'url',
|
||||
return getMockDataSource<PromOptions>({
|
||||
jsonData: {
|
||||
timeInterval: '1m',
|
||||
queryTimeout: '1m',
|
||||
httpMethod: 'GET',
|
||||
directUrl: 'url',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -91,28 +91,28 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
{
|
||||
path: '/datasources',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage')
|
||||
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/pages/DataSourcesListPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/datasources/edit/:uid/',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage'
|
||||
)
|
||||
() => import(/* webpackChunkName: "EditDataSourcePage"*/ '../features/datasources/pages/EditDataSourcePage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/datasources/edit/:uid/dashboards',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards')
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/pages/DataSourceDashboardsPage'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/datasources/new',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage')
|
||||
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/pages/NewDataSourcePage')
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DataSourcePluginMeta, DataSourceSettings, LayoutMode } from '@grafana/data';
|
||||
import { HealthCheckResultDetails } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
||||
import { GenericDataSourcePlugin } from 'app/features/datasources/settings/PluginSettings';
|
||||
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
|
||||
|
||||
export interface DataSourcesState {
|
||||
dataSources: DataSourceSettings[];
|
||||
|
@ -40,7 +40,10 @@ angular.module('grafana.directives', []);
|
||||
angular.module('grafana.filters', []);
|
||||
angular.module('grafana.routes', ['ngRoute']);
|
||||
|
||||
jest.mock('../app/core/core', () => ({ appEvents: testAppEvents }));
|
||||
jest.mock('../app/core/core', () => ({
|
||||
...jest.requireActual('../app/core/core'),
|
||||
appEvents: testAppEvents,
|
||||
}));
|
||||
jest.mock('../app/angular/partials', () => ({}));
|
||||
jest.mock('../app/features/plugins/plugin_loader', () => ({}));
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user