Datasource: Overhaul plugin error handling and action buttons (#67014)

* - initial work on data source config page

* - add links to test status box
- add tracking function

* - add test for the DataSourceConfigAlert component

* - fix flicker of the alert box

* - fix the build

* - small improvements

* - fix failing build

* - fix failing unit tests

* - prettier and betterer fixes

* - fix failing e2e tests

* - fix build again

* - rewrite solution according to the PR comments

* - cleanup

* - fix failing e2e

* - use absolute path in link

* Minor fixes

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/67552/head
Kuba Siemiatkowski 2 years ago committed by GitHub
parent fe59b65f9e
commit f8faacd54a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      .betterer.results
  2. 9
      packages/grafana-data/src/types/datasource.ts
  3. 9
      packages/grafana-runtime/src/utils/DataSourceWithBackend.ts
  4. 3
      plugins-bundled/internal/input-datasource/src/InputDatasource.ts
  5. 2
      public/app/core/services/backend_srv.ts
  6. 31
      public/app/core/specs/backend_srv.test.ts
  7. 5
      public/app/features/dashboard/services/PublicDashboardDataSource.ts
  8. 10
      public/app/features/datasources/api.ts
  9. 3
      public/app/features/datasources/components/ButtonRow.test.tsx
  10. 14
      public/app/features/datasources/components/ButtonRow.tsx
  11. 98
      public/app/features/datasources/components/DataSourceTestingStatus.tsx
  12. 8
      public/app/features/datasources/components/EditDataSource.tsx
  13. 15
      public/app/features/datasources/pages/EditDataSourcePage.test.tsx
  14. 5
      public/app/features/datasources/state/actions.test.ts
  15. 74
      public/app/features/datasources/state/actions.ts
  16. 2
      public/app/features/datasources/state/reducers.ts
  17. 2
      public/app/features/datasources/types.ts
  18. 4
      public/app/plugins/datasource/cloudwatch/__mocks__/logsTestContext.ts
  19. 12
      public/app/plugins/datasource/dashboard/datasource.ts
  20. 5
      public/app/plugins/datasource/grafana/datasource.ts
  21. 5
      public/app/plugins/datasource/mixed/MixedDataSource.ts
  22. 9
      public/test/mocks/datasource_srv.ts

@ -380,8 +380,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "25"], [0, 0, 0, "Unexpected any. Specify a different type.", "25"],
[0, 0, 0, "Unexpected any. Specify a different type.", "26"], [0, 0, 0, "Unexpected any. Specify a different type.", "26"],
[0, 0, 0, "Unexpected any. Specify a different type.", "27"], [0, 0, 0, "Unexpected any. Specify a different type.", "27"],
[0, 0, 0, "Unexpected any. Specify a different type.", "28"], [0, 0, 0, "Unexpected any. Specify a different type.", "28"]
[0, 0, 0, "Unexpected any. Specify a different type.", "29"]
], ],
"packages/grafana-data/src/types/explore.ts:5381": [ "packages/grafana-data/src/types/explore.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@ -841,8 +840,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"]
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
], ],
"packages/grafana-runtime/src/utils/plugin.ts:5381": [ "packages/grafana-runtime/src/utils/plugin.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@ -2604,7 +2602,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
], ],
"public/app/features/datasources/components/DataSourceTestingStatus.tsx:5381": [ "public/app/features/datasources/components/DataSourceTestingStatus.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
], ],
"public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [ "public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
@ -2618,8 +2617,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"] [0, 0, 0, "Unexpected any. Specify a different type.", "5"]
], ],
"public/app/features/datasources/state/actions.ts:5381": [ "public/app/features/datasources/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
], ],
"public/app/features/datasources/state/navModel.ts:5381": [ "public/app/features/datasources/state/navModel.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],

@ -260,7 +260,7 @@ abstract class DataSourceApi<
* a TestingStatus object. Unknown errors and HTTP errors can be re-thrown and will be handled here: * a TestingStatus object. Unknown errors and HTTP errors can be re-thrown and will be handled here:
* public/app/features/datasources/state/actions.ts * public/app/features/datasources/state/actions.ts
*/ */
abstract testDatasource(): Promise<any>; abstract testDatasource(): Promise<TestDataSourceResponse>;
/** /**
* Override to skip executing a query * Override to skip executing a query
@ -473,6 +473,13 @@ export interface DataQueryResponse {
traceIds?: string[]; traceIds?: string[];
} }
export interface TestDataSourceResponse {
status: string;
message: string;
error?: Error;
details?: { message?: string; verboseMessage?: string };
}
export enum DataQueryErrorType { export enum DataQueryErrorType {
Cancelled = 'cancelled', Cancelled = 'cancelled',
Timeout = 'timeout', Timeout = 'timeout',

@ -7,6 +7,7 @@ import {
DataQuery, DataQuery,
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
DataSourceApi, DataSourceApi,
DataSourceInstanceSettings, DataSourceInstanceSettings,
DataSourceJsonData, DataSourceJsonData,
@ -342,7 +343,7 @@ class DataSourceWithBackend<
* Checks the plugin health * Checks the plugin health
* see public/app/features/datasources/state/actions.ts for what needs to be returned here * see public/app/features/datasources/state/actions.ts for what needs to be returned here
*/ */
async testDatasource(): Promise<any> { async testDatasource(): Promise<TestDataSourceResponse> {
return this.callHealthCheck().then((res) => { return this.callHealthCheck().then((res) => {
if (res.status === HealthStatus.OK) { if (res.status === HealthStatus.OK) {
return { return {
@ -351,7 +352,11 @@ class DataSourceWithBackend<
}; };
} }
throw new HealthCheckError(res.message, res.details); return Promise.reject({
status: 'error',
message: res.message,
error: new HealthCheckError(res.message, res.details),
});
}); });
} }
} }

@ -2,6 +2,7 @@
import { import {
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
DataSourceApi, DataSourceApi,
DataSourceInstanceSettings, DataSourceInstanceSettings,
MetricFindValue, MetricFindValue,
@ -68,7 +69,7 @@ export class InputDatasource extends DataSourceApi<InputQuery, InputOptions> {
return Promise.resolve({ data: results }); return Promise.resolve({ data: results });
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let rowCount = 0; let rowCount = 0;
let info = `${this.data.length} Series:`; let info = `${this.data.length} Series:`;

@ -301,7 +301,7 @@ export class BackendSrv implements BackendService {
return; return;
} }
// is showSuccessAlert is undefined we only show alerts non GET request, non data query and local api requests // if showSuccessAlert is undefined we only show alerts non GET request, non data query and local api requests
if ( if (
config.showSuccessAlert === undefined && config.showSuccessAlert === undefined &&
(config.method === 'GET' || isDataQuery(config.url) || !isLocalUrl(config.url)) (config.method === 'GET' || isDataQuery(config.url) || !isLocalUrl(config.url))

@ -125,15 +125,18 @@ describe('backendSrv', () => {
}); });
describe('request', () => { describe('request', () => {
const testMessage = 'Datasource updated';
const errorMessage = 'UnAuthorized';
describe('when making a successful call and conditions for showSuccessAlert are not favorable', () => { describe('when making a successful call and conditions for showSuccessAlert are not favorable', () => {
it('then it should return correct result and not emit anything', async () => { it('then it should return correct result and not emit anything', async () => {
const { backendSrv, appEventsMock, expectRequestCallChain } = getTestContext({ const { backendSrv, appEventsMock, expectRequestCallChain } = getTestContext({
data: { message: 'A message' }, data: { message: testMessage },
}); });
const url = '/api/dashboard/'; const url = '/api/dashboard/';
const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: false }); const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: false });
expect(result).toEqual({ message: 'A message' }); expect(result).toEqual({ message: testMessage });
expect(appEventsMock.emit).not.toHaveBeenCalled(); expect(appEventsMock.emit).not.toHaveBeenCalled();
expectRequestCallChain({ url, method: 'DELETE', showSuccessAlert: false }); expectRequestCallChain({ url, method: 'DELETE', showSuccessAlert: false });
}); });
@ -142,14 +145,14 @@ describe('backendSrv', () => {
describe('when making a successful call and conditions for showSuccessAlert are favorable', () => { describe('when making a successful call and conditions for showSuccessAlert are favorable', () => {
it('then it should emit correct message', async () => { it('then it should emit correct message', async () => {
const { backendSrv, appEventsMock, expectRequestCallChain } = getTestContext({ const { backendSrv, appEventsMock, expectRequestCallChain } = getTestContext({
data: { message: 'A message' }, data: { message: testMessage },
}); });
const url = '/api/dashboard/'; const url = '/api/dashboard/';
const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: true }); const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: true });
expect(result).toEqual({ message: 'A message' }); expect(result).toEqual({ message: testMessage });
expect(appEventsMock.emit).toHaveBeenCalledTimes(1); expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertSuccess, ['A message']); expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertSuccess, [testMessage]);
expectRequestCallChain({ url, method: 'DELETE', showSuccessAlert: true }); expectRequestCallChain({ url, method: 'DELETE', showSuccessAlert: true });
}); });
}); });
@ -161,8 +164,8 @@ describe('backendSrv', () => {
const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({ const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({
ok: false, ok: false,
status: 401, status: 401,
statusText: 'UnAuthorized', statusText: errorMessage,
data: { message: 'UnAuthorized' }, data: { message: errorMessage },
url, url,
}); });
@ -174,8 +177,8 @@ describe('backendSrv', () => {
.request({ url, method: 'GET', retry: 0 }) .request({ url, method: 'GET', retry: 0 })
.catch((error) => { .catch((error) => {
expect(error.status).toBe(401); expect(error.status).toBe(401);
expect(error.statusText).toBe('UnAuthorized'); expect(error.statusText).toBe(errorMessage);
expect(error.data).toEqual({ message: 'UnAuthorized' }); expect(error.data).toEqual({ message: errorMessage });
expect(appEventsMock.emit).not.toHaveBeenCalled(); expect(appEventsMock.emit).not.toHaveBeenCalled();
expect(logoutMock).not.toHaveBeenCalled(); expect(logoutMock).not.toHaveBeenCalled();
expect(backendSrv.loginPing).toHaveBeenCalledTimes(1); expect(backendSrv.loginPing).toHaveBeenCalledTimes(1);
@ -183,9 +186,9 @@ describe('backendSrv', () => {
jest.advanceTimersByTime(50); jest.advanceTimersByTime(50);
}) })
.catch((error) => { .catch((error) => {
expect(error).toEqual({ message: 'UnAuthorized' }); expect(error).toEqual({ message: errorMessage });
expect(appEventsMock.emit).toHaveBeenCalledTimes(1); expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertWarning, ['UnAuthorized', '']); expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertWarning, [errorMessage, '']);
}); });
}); });
}); });
@ -196,7 +199,7 @@ describe('backendSrv', () => {
const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({ const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({
ok: false, ok: false,
status: 401, status: 401,
statusText: 'UnAuthorized', statusText: errorMessage,
data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } }, data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } },
url, url,
}); });
@ -226,8 +229,8 @@ describe('backendSrv', () => {
const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({ const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({
ok: false, ok: false,
status: 401, status: 401,
statusText: 'UnAuthorized', statusText: errorMessage,
data: { message: 'UnAuthorized' }, data: { message: errorMessage },
}); });
backendSrv.loginPing = jest backendSrv.loginPing = jest

@ -5,6 +5,7 @@ import {
DataQuery, DataQuery,
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
DataSourceApi, DataSourceApi,
DataSourceJsonData, DataSourceJsonData,
DataSourcePluginMeta, DataSourcePluginMeta,
@ -151,7 +152,7 @@ export class PublicDashboardDataSource extends DataSourceApi<DataQuery, DataSour
return { data: [toDataFrame(annotations)] }; return { data: [toDataFrame(annotations)] };
} }
testDatasource(): Promise<null> { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve(null); return Promise.resolve({ message: '', status: '' });
} }
} }

@ -68,7 +68,13 @@ export const createDataSource = (dataSource: Partial<DataSourceSettings>) =>
export const getDataSourcePlugins = () => getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' }); export const getDataSourcePlugins = () => getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
export const updateDataSource = (dataSource: DataSourceSettings) => export const updateDataSource = (dataSource: DataSourceSettings) => {
getBackendSrv().put(`/api/datasources/uid/${dataSource.uid}`, dataSource); // we're setting showErrorAlert and showSuccessAlert to false to suppress the popover notifications. Request result will now be
// handled by the data source config page
return getBackendSrv().put(`/api/datasources/uid/${dataSource.uid}`, dataSource, {
showErrorAlert: false,
showSuccessAlert: false,
});
};
export const deleteDataSource = (uid: string) => getBackendSrv().delete(`/api/datasources/uid/${uid}`); export const deleteDataSource = (uid: string) => getBackendSrv().delete(`/api/datasources/uid/${uid}`);

@ -10,7 +10,6 @@ const setup = (propOverrides?: object) => {
canSave: false, canSave: false,
onSubmit: jest.fn(), onSubmit: jest.fn(),
onTest: jest.fn(), onTest: jest.fn(),
exploreUrl: '/explore',
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
@ -22,7 +21,7 @@ describe('<ButtonRow>', () => {
it('should render component', () => { it('should render component', () => {
setup(); setup();
expect(screen.getByRole('link', { name: 'Explore' })).toBeInTheDocument(); expect(screen.getByText('Test')).toBeInTheDocument();
}); });
it('should render save & test', () => { it('should render save & test', () => {
setup({ canSave: true }); setup({ canSave: true });

@ -1,31 +1,23 @@
import React from 'react'; import React from 'react';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Button, LinkButton } from '@grafana/ui'; import { Button } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
export interface Props { export interface Props {
exploreUrl: string;
canSave: boolean; canSave: boolean;
onSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void; onSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void;
onTest: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onTest: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
} }
export function ButtonRow({ canSave, onSubmit, onTest, exploreUrl }: Props) { export function ButtonRow({ canSave, onSubmit, onTest }: Props) {
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
return ( return (
<div className="gf-form-button-row"> <div className="gf-form-button-row">
<LinkButton variant="secondary" fill="solid" href={exploreUrl} disabled={!canExploreDataSources}>
Explore
</LinkButton>
{canSave && ( {canSave && (
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={!canSave} disabled={!canSave}
onClick={(event) => onSubmit(event)} onClick={onSubmit}
aria-label={selectors.pages.DataSource.saveAndTest} aria-label={selectors.pages.DataSource.saveAndTest}
> >
Save &amp; test Save &amp; test

@ -1,30 +1,108 @@
import React from 'react'; import { css, cx } from '@emotion/css';
import React, { HTMLAttributes } from 'react';
import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { TestingStatus } from '@grafana/runtime'; import { TestingStatus, config } from '@grafana/runtime';
import { Alert } from '@grafana/ui'; import { AlertVariant, Alert, useTheme2, Link } from '@grafana/ui';
import { contextSrv } from '../../../core/core';
import { AccessControlAction } from '../../../types';
import { trackCreateDashboardClicked } from '../tracking';
export type Props = { export type Props = {
testingStatus?: TestingStatus; testingStatus?: TestingStatus;
exploreUrl: string;
dataSource: DataSourceSettingsType;
};
interface AlertMessageProps extends HTMLAttributes<HTMLDivElement> {
title: string;
severity?: AlertVariant;
exploreUrl: string;
dataSourceId: string;
onDashboardLinkClicked: () => void;
}
const getStyles = (theme: GrafanaTheme2, hasTitle: boolean) => {
return {
content: css`
color: ${theme.colors.text.secondary};
padding-top: ${hasTitle ? theme.spacing(1) : 0};
max-height: 50vh;
overflow-y: auto;
`,
disabled: css`
pointer-events: none;
color: ${theme.colors.text.secondary};
`,
};
};
const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkClicked }: AlertMessageProps) => {
const theme = useTheme2();
const hasTitle = Boolean(title);
const styles = getStyles(theme, hasTitle);
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
return (
<div className={styles.content}>
Next, you can start to visualize data by{' '}
<Link
aria-label={`Create a dashboard`}
href={`/dashboard/new-with-ds/${dataSourceId}`}
className="external-link"
onClick={onDashboardLinkClicked}
>
building a dashboard
</Link>
, or by querying data in the{' '}
<Link
aria-label={`Explore data`}
className={cx('external-link', {
[`${styles.disabled}`]: !canExploreDataSources,
'test-disabled': !canExploreDataSources,
})}
href={exploreUrl}
>
Explore view
</Link>
.
</div>
);
}; };
export function DataSourceTestingStatus({ testingStatus }: Props) { AlertSuccessMessage.displayName = 'AlertSuccessMessage';
const isError = testingStatus?.status === 'error';
export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource }: Props) {
const severity = testingStatus?.status ? (testingStatus?.status as AlertVariant) : 'error';
const message = testingStatus?.message; const message = testingStatus?.message;
const detailsMessage = testingStatus?.details?.message; const detailsMessage = testingStatus?.details?.message;
const detailsVerboseMessage = testingStatus?.details?.verboseMessage; const detailsVerboseMessage = testingStatus?.details?.verboseMessage;
const onDashboardLinkClicked = () => {
trackCreateDashboardClicked({
grafana_version: config.buildInfo.version,
datasource_uid: dataSource.uid,
plugin_name: dataSource.typeName,
path: location.pathname,
});
};
if (message) { if (message) {
return ( return (
<div className="gf-form-group p-t-2"> <div className="gf-form-group p-t-2">
<Alert <Alert severity={severity} title={message} aria-label={e2eSelectors.pages.DataSource.alert}>
severity={isError ? 'error' : 'success'}
title={message}
aria-label={e2eSelectors.pages.DataSource.alert}
>
{testingStatus?.details && ( {testingStatus?.details && (
<> <>
{detailsMessage} {detailsMessage}
{severity === 'success' ? (
<AlertSuccessMessage
title={message}
exploreUrl={exploreUrl}
dataSourceId={dataSource.uid}
onDashboardLinkClicked={onDashboardLinkClicked}
/>
) : null}
{detailsVerboseMessage ? ( {detailsVerboseMessage ? (
<details style={{ whiteSpace: 'pre-wrap' }}>{String(detailsVerboseMessage)}</details> <details style={{ whiteSpace: 'pre-wrap' }}>{String(detailsVerboseMessage)}</details>
) : null} ) : null}

@ -119,7 +119,11 @@ export function EditDataSourceView({
const onSubmit = async (e: React.MouseEvent<HTMLButtonElement> | React.FormEvent<HTMLFormElement>) => { const onSubmit = async (e: React.MouseEvent<HTMLButtonElement> | React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
try {
await onUpdate({ ...dataSource }); await onUpdate({ ...dataSource });
} catch (err) {
return;
}
onTest(); onTest();
}; };
@ -173,9 +177,9 @@ export function EditDataSourceView({
</DataSourcePluginContextProvider> </DataSourcePluginContextProvider>
)} )}
<DataSourceTestingStatus testingStatus={testingStatus} /> <DataSourceTestingStatus testingStatus={testingStatus} exploreUrl={exploreUrl} dataSource={dataSource} />
<ButtonRow onSubmit={onSubmit} onTest={onTest} exploreUrl={exploreUrl} canSave={!readOnly && hasWriteRights} /> <ButtonRow onSubmit={onSubmit} onTest={onTest} canSave={!readOnly && hasWriteRights} />
</form> </form>
); );
} }

@ -4,7 +4,7 @@ import { Store } from 'redux';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
import { LayoutModes } from '@grafana/data'; import { LayoutModes } from '@grafana/data';
import { setAngularLoader } from '@grafana/runtime'; import { setAngularLoader, config } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
@ -102,12 +102,23 @@ describe('<EditDataSourcePage>', () => {
// Title // Title
expect(screen.queryByText(name)).toBeVisible(); expect(screen.queryByText(name)).toBeVisible();
// Buttons
expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible();
// wait for the rest of the async processes to finish
expect(await screen.findByText(name)).toBeVisible();
});
it('should show updated action buttons when topnav is on', async () => {
config.featureToggles.topnav = true;
setup(uid, store);
await waitFor(() => { await waitFor(() => {
// Buttons // Buttons
expect(screen.queryByRole('button', { name: /Delete/i })).toBeVisible(); expect(screen.queryByRole('button', { name: /Delete/i })).toBeVisible();
expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible(); expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible();
expect(screen.queryByRole('link', { name: /Build a dashboard/i })).toBeVisible(); expect(screen.queryByRole('link', { name: /Build a dashboard/i })).toBeVisible();
expect(screen.queryAllByRole('link', { name: /Explore/i })).toHaveLength(2); expect(screen.queryAllByRole('link', { name: /Explore/i })).toHaveLength(1);
}); });
}); });
}); });

@ -217,7 +217,7 @@ describe('testDataSource', () => {
({ ({
get: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue({
testDatasource: jest.fn().mockReturnValue({ testDatasource: jest.fn().mockReturnValue({
status: '', status: 'success',
message: '', message: '',
}), }),
type: 'cloudwatch', type: 'cloudwatch',
@ -228,8 +228,9 @@ describe('testDataSource', () => {
}; };
const state = { const state = {
testingStatus: { testingStatus: {
status: '', status: 'success',
message: '', message: '',
details: {},
}, },
}; };
const dispatchedActions = await thunkTester(state) const dispatchedActions = await thunkTester(state)

@ -57,6 +57,40 @@ export interface TestDataSourceDependencies {
getBackendSrv: typeof getBackendSrv; getBackendSrv: typeof getBackendSrv;
} }
type parseDataSourceSaveResponse = {
message?: string | undefined;
status?: string;
details?: HealthCheckResultDetails | { message?: string; verboseMessage?: string };
};
const parseHealthCheckError = (errorResponse: any): parseDataSourceSaveResponse => {
let message: string | undefined;
let details: HealthCheckResultDetails;
if (errorResponse.error && errorResponse.error instanceof HealthCheckError) {
message = errorResponse.error.message;
details = errorResponse.error.details;
} else if (isFetchError(errorResponse)) {
message = errorResponse.data.message ?? `HTTP error ${errorResponse.statusText}`;
} else if (errorResponse instanceof Error) {
message = errorResponse.message;
}
return { message, details };
};
const parseHealthCheckSuccess = (response: any): parseDataSourceSaveResponse => {
let message: string | undefined;
let status: string;
let details: { message?: string; verboseMessage?: string };
status = response.status;
message = response.message;
details = response.details;
return { status, message, details };
};
export const initDataSourceSettings = ( export const initDataSourceSettings = (
uid: string, uid: string,
dependencies: InitDataSourceSettingDependencies = { dependencies: InitDataSourceSettingDependencies = {
@ -112,7 +146,9 @@ export const testDataSource = (
try { try {
const result = await dsApi.testDatasource(); const result = await dsApi.testDatasource();
dispatch(testDataSourceSucceeded(result)); const parsedResult = parseHealthCheckSuccess({ ...result, details: { ...result.details } });
dispatch(testDataSourceSucceeded(parsedResult));
trackDataSourceTested({ trackDataSourceTested({
grafana_version: config.buildInfo.version, grafana_version: config.buildInfo.version,
plugin_id: dsApi.type, plugin_id: dsApi.type,
@ -121,19 +157,9 @@ export const testDataSource = (
path: editLink, path: editLink,
}); });
} catch (err) { } catch (err) {
let message: string | undefined; const formattedError = parseHealthCheckError(err);
let details: HealthCheckResultDetails;
if (err instanceof HealthCheckError) { dispatch(testDataSourceFailed({ ...formattedError }));
message = err.message;
details = err.details;
} else if (isFetchError(err)) {
message = err.data.message ?? `HTTP error ${err.statusText}`;
} else if (err instanceof Error) {
message = err.message;
}
dispatch(testDataSourceFailed({ message, details }));
trackDataSourceTested({ trackDataSourceTested({
grafana_version: config.buildInfo.version, grafana_version: config.buildInfo.version,
plugin_id: dsApi.type, plugin_id: dsApi.type,
@ -216,6 +242,7 @@ export function addDataSource(
isDefault: isFirstDataSource, isDefault: isFirstDataSource,
}; };
// TODO: typo in name
if (nameExits(dataSources, newInstance.name)) { if (nameExits(dataSources, newInstance.name)) {
newInstance.name = findNewName(dataSources, newInstance.name); newInstance.name = findNewName(dataSources, newInstance.name);
} }
@ -248,9 +275,23 @@ export function loadDataSourcePlugins(): ThunkResult<void> {
} }
export function updateDataSource(dataSource: DataSourceSettings) { export function updateDataSource(dataSource: DataSourceSettings) {
return async (dispatch: (dataSourceSettings: ThunkResult<Promise<DataSourceSettings>>) => DataSourceSettings) => { return async (
dispatch: (
dataSourceSettings: ThunkResult<Promise<DataSourceSettings>> | { payload: unknown; type: string }
) => DataSourceSettings
) => {
try {
await api.updateDataSource(dataSource); await api.updateDataSource(dataSource);
} catch (err: any) {
const formattedError = parseHealthCheckError(err);
dispatch(testDataSourceFailed(formattedError));
return Promise.reject(dataSource);
}
await getDatasourceSrv().reload(); await getDatasourceSrv().reload();
return dispatch(loadDataSource(dataSource.uid)); return dispatch(loadDataSource(dataSource.uid));
}; };
} }
@ -259,6 +300,7 @@ export function deleteLoadedDataSource(): ThunkResult<void> {
return async (dispatch, getStore) => { return async (dispatch, getStore) => {
const { uid } = getStore().dataSources.dataSource; const { uid } = getStore().dataSources.dataSource;
try {
await api.deleteDataSource(uid); await api.deleteDataSource(uid);
await getDatasourceSrv().reload(); await getDatasourceSrv().reload();
@ -267,5 +309,9 @@ export function deleteLoadedDataSource(): ThunkResult<void> {
: '/datasources'; : '/datasources';
locationService.push(datasourcesUrl); locationService.push(datasourcesUrl);
} catch (err) {
const formattedError = parseHealthCheckError(err);
dispatch(testDataSourceFailed(formattedError));
}
}; };
} }

@ -146,7 +146,7 @@ export const dataSourceSettingsReducer = (
return { return {
...state, ...state,
testingStatus: { testingStatus: {
message: 'Testing...', message: 'Testing... this could take up to a couple of minutes',
status: 'info', status: 'info',
}, },
}; };

@ -14,3 +14,5 @@ export type DataSourcesRoutes = {
List: string; List: string;
Dashboards: string; Dashboards: string;
}; };
export type DataSourceTestStatus = 'success' | 'warning' | 'error';

@ -12,10 +12,10 @@ import {
DataQuery, DataQuery,
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
} from '@grafana/data'; } from '@grafana/data';
import { GetDataSourceListFilters, setDataSourceSrv } from '@grafana/runtime'; import { GetDataSourceListFilters, setDataSourceSrv } from '@grafana/runtime';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchLogsQueryStatus } from '../types'; import { CloudWatchLogsQueryStatus } from '../types';
import { meta, setupMockedDataSource } from './CloudWatchDataSource'; import { meta, setupMockedDataSource } from './CloudWatchDataSource';
@ -62,7 +62,7 @@ export function setupForLogs() {
): Observable<DataQueryResponse> | Promise<DataQueryResponse> { ): Observable<DataQueryResponse> | Promise<DataQueryResponse> {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
testDatasource: function (): Promise<CloudWatchDatasource> { testDatasource: function (): Promise<TestDataSourceResponse> {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
meta: meta, meta: meta,

@ -1,4 +1,10 @@
import { DataSourceApi, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings } from '@grafana/data'; import {
DataSourceApi,
DataQueryRequest,
DataQueryResponse,
DataSourceInstanceSettings,
TestDataSourceResponse,
} from '@grafana/data';
import { DashboardQuery } from './types'; import { DashboardQuery } from './types';
@ -18,7 +24,7 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
return Promise.reject('This should not be called directly'); return Promise.reject('This should not be called directly');
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve({}); return Promise.resolve({ message: '', status: '' });
} }
} }

@ -9,6 +9,7 @@ import {
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
DataSourceInstanceSettings, DataSourceInstanceSettings,
TestDataSourceResponse,
isValidLiveChannelAddress, isValidLiveChannelAddress,
MutableDataFrame, MutableDataFrame,
parseLiveChannelAddress, parseLiveChannelAddress,
@ -252,8 +253,8 @@ export class GrafanaDatasource extends DataSourceWithBackend<GrafanaQuery> {
return { data: [toDataFrame(annotations)] }; return { data: [toDataFrame(annotations)] };
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve(); return Promise.resolve({ message: '', status: '' });
} }
} }

@ -6,6 +6,7 @@ import {
DataQuery, DataQuery,
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
DataSourceApi, DataSourceApi,
DataSourceInstanceSettings, DataSourceInstanceSettings,
LoadingState, LoadingState,
@ -94,8 +95,8 @@ export class MixedDatasource extends DataSourceApi<DataQuery> {
return forkJoin(runningQueries).pipe(flattenResponses(), map(this.finalizeResponses), mergeAll()); return forkJoin(runningQueries).pipe(flattenResponses(), map(this.finalizeResponses), mergeAll());
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve({}); return Promise.resolve({ message: '', status: '' });
} }
private isQueryable(query: BatchedQueries): boolean { private isQueryable(query: BatchedQueries): boolean {

@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
import { import {
DataQueryRequest, DataQueryRequest,
DataQueryResponse, DataQueryResponse,
TestDataSourceResponse,
DataSourceApi, DataSourceApi,
DataSourceInstanceSettings, DataSourceInstanceSettings,
DataSourcePluginMeta, DataSourcePluginMeta,
@ -57,8 +58,8 @@ export class MockDataSourceApi extends DataSourceApi {
}); });
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve(); return Promise.resolve({ message: '', status: '' });
} }
setupMixed(value: boolean) { setupMixed(value: boolean) {
@ -99,7 +100,7 @@ export class MockObservableDataSourceApi extends DataSourceApi {
}); });
} }
testDatasource() { testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve(); return Promise.resolve({ message: '', status: '' });
} }
} }

Loading…
Cancel
Save