mirror of https://github.com/grafana/grafana
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>pull/50447/head
parent
9f4683b3d0
commit
d6d49d8ba3
@ -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 |
||||
); |
||||
|
||||
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', |
||||
}, |
||||
|
||||
return dataSources as DataSourceSettings[]; |
||||
}; |
||||
module: 'plugins/datasource-test/module', |
||||
baseUrl: 'public/plugins/datasource-test', |
||||
}, |
||||
overrides |
||||
); |
||||
|
||||
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: {}, |
||||
}; |
||||
}; |
||||
export const getMockDataSourceSettingsState = (overrides?: Partial<DataSourceSettingsState>): DataSourceSettingsState => |
||||
merge( |
||||
{ |
||||
plugin: { |
||||
meta: getMockDataSourceMeta(), |
||||
components: {}, |
||||
}, |
||||
testingStatus: {}, |
||||
loadError: null, |
||||
loading: false, |
||||
}, |
||||
overrides |
||||
); |
||||
|
||||
@ -0,0 +1,2 @@ |
||||
export * from './dataSourcesMocks'; |
||||
export * from './store.navIndex.mock'; |
||||
@ -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); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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}`); |
||||
@ -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>; |
||||
} |
||||
@ -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; |
||||
} |
||||
`}
|
||||
/> |
||||
); |
||||
} |
||||
@ -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', |
||||
}), |
||||
}; |
||||
}; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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> |
||||
); |
||||
} |
||||
@ -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; |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
@ -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); |
||||
@ -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, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,6 @@ |
||||
export * from './actions'; |
||||
export * from './buildCategories'; |
||||
export * from './hooks'; |
||||
export * from './navModel'; |
||||
export * from './reducers'; |
||||
export * from './selectors'; |
||||
@ -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; |
||||
}; |
||||
@ -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-'); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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); |
||||
} |
||||
@ -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,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', |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
Loading…
Reference in new issue