Commit 81e955e6 by Torkel Ödegaard Committed by GitHub

BackendSrv: Cancellable requests & Observable all the way (#25746)

* BackendSrv: Observable all the way POC

* starting to unify code paths

* tests pass

* Unified error handling

* Single request path and error handling

* Fixed ts issue

* another ts issu

* Added back old requestId cancellation

* Slow progress trying to grasp the full picture of cancellation

* Updates

* refactoring

* Remove a bunch of stuff from backendSrv

* Removed another function

* Do not show error alerts for data queries

* Muu

* Updated comment

* fixed ts issue

* unify request options type

* Made query inspector subscribe to backendSrv stream instead of legacy app events

* Add back support for err.isHandled to limit scope

* never show success alerts

* Updated tests

* Fixing tests

* Minor weak

* Improved logic for the showErrorAlert and showSuccessAlert boolean flags, now they work more logically

* Fix issue
parent ba4a8256
......@@ -16,8 +16,9 @@ The request function can be used to perform a remote call by specifying a [Backe
<b>Signature</b>
```typescript
export interface BackendSrv
export interface BackendSrv
```
<b>Import</b>
```typescript
......@@ -26,19 +27,21 @@ import { BackendSrv } from '@grafana/runtime';
## Remarks
By default Grafana will display an error message alert if the remote call fails. If you want to prevent this from happending you need to catch the error thrown by the BackendSrv and set the `isHandled = true` on the incoming error.
By default Grafana will display an error message alert if the remote call fails. If you want to prevent this from happending you need to catch the error thrown by the BackendSrv and set the `showErrorAlert = true` on the request options object.
> In versions prior to v7.2 you disable the notification alert by setting isHandled on the caught error
<b>Methods</b>
| Method | Description |
| --- | --- |
| [datasourceRequest(options)](#datasourcerequest-method) | Special function used to communicate with datasources that will emit core events that the Grafana QueryInspector and QueryEditor is listening for to be able to display datasource query information. Can be skipped by adding <code>option.silent</code> when initializing the request. |
| [delete(url)](#delete-method) | |
| [get(url, params, requestId)](#get-method) | |
| [patch(url, data)](#patch-method) | |
| [post(url, data)](#post-method) | |
| [put(url, data)](#put-method) | |
| [request(options)](#request-method) | |
| Method | Description |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [datasourceRequest(options)](#datasourcerequest-method) | Special function used to communicate with datasources that will emit core events that the Grafana QueryInspector and QueryEditor is listening for to be able to display datasource query information. Can be skipped by adding <code>option.silent</code> when initializing the request. |
| [delete(url)](#delete-method) | |
| [get(url, params, requestId)](#get-method) | |
| [patch(url, data)](#patch-method) | |
| [post(url, data)](#post-method) | |
| [put(url, data)](#put-method) | |
| [request(options)](#request-method) | |
### datasourceRequest method
......@@ -49,11 +52,12 @@ Special function used to communicate with datasources that will emit core events
```typescript
datasourceRequest(options: BackendSrvRequest): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| options | <code>BackendSrvRequest</code> | |
| Parameter | Type | Description |
| --------- | ------------------------------ | ----------- |
| options | <code>BackendSrvRequest</code> | |
<b>Returns:</b>
......@@ -66,11 +70,12 @@ datasourceRequest(options: BackendSrvRequest): Promise<any>;
```typescript
delete(url: string): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| url | <code>string</code> | |
| Parameter | Type | Description |
| --------- | ------------------- | ----------- |
| url | <code>string</code> | |
<b>Returns:</b>
......@@ -83,13 +88,14 @@ delete(url: string): Promise<any>;
```typescript
get(url: string, params?: any, requestId?: string): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| url | <code>string</code> | |
| params | <code>any</code> | |
| requestId | <code>string</code> | |
| Parameter | Type | Description |
| --------- | ------------------- | ----------- |
| url | <code>string</code> | |
| params | <code>any</code> | |
| requestId | <code>string</code> | |
<b>Returns:</b>
......@@ -102,12 +108,13 @@ get(url: string, params?: any, requestId?: string): Promise<any>;
```typescript
patch(url: string, data?: any): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| url | <code>string</code> | |
| data | <code>any</code> | |
| Parameter | Type | Description |
| --------- | ------------------- | ----------- |
| url | <code>string</code> | |
| data | <code>any</code> | |
<b>Returns:</b>
......@@ -120,12 +127,13 @@ patch(url: string, data?: any): Promise<any>;
```typescript
post(url: string, data?: any): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| url | <code>string</code> | |
| data | <code>any</code> | |
| Parameter | Type | Description |
| --------- | ------------------- | ----------- |
| url | <code>string</code> | |
| data | <code>any</code> | |
<b>Returns:</b>
......@@ -138,12 +146,13 @@ post(url: string, data?: any): Promise<any>;
```typescript
put(url: string, data?: any): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| url | <code>string</code> | |
| data | <code>any</code> | |
| Parameter | Type | Description |
| --------- | ------------------- | ----------- |
| url | <code>string</code> | |
| data | <code>any</code> | |
<b>Returns:</b>
......@@ -156,13 +165,13 @@ put(url: string, data?: any): Promise<any>;
```typescript
request(options: BackendSrvRequest): Promise<any>;
```
<b>Parameters</b>
| Parameter | Type | Description |
| --- | --- | --- |
| options | <code>BackendSrvRequest</code> | |
| Parameter | Type | Description |
| --------- | ------------------------------ | ----------- |
| options | <code>BackendSrvRequest</code> | |
<b>Returns:</b>
`Promise<any>`
import { Observable } from 'rxjs';
/**
* Used to initiate a remote call via the {@link BackendSrv}
*
* @public
*/
export type BackendSrvRequest = {
/**
* Request URL
*/
url: string;
/**
* Number of times to retry the remote call if it fails.
*/
......@@ -15,7 +21,7 @@ export type BackendSrvRequest = {
* Please have a look at {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}
* for supported headers.
*/
headers?: any;
headers?: Record<string, any>;
/**
* HTTP verb to perform in the remote call GET, POST, PUT etc.
......@@ -23,22 +29,88 @@ export type BackendSrvRequest = {
method?: string;
/**
* If set to true an alert with the response message will be displayed
* upon successful remote call
* Set to false an success application alert box will not be shown for successful PUT, DELETE, POST requests
*/
showSuccessAlert?: boolean;
/**
* Set to false to not show an application alert box for request errors
*/
showErrorAlert?: boolean;
/**
* Provided by the initiator to identify a particular remote call. An example
* of this is when a datasource plugin triggers a query. If the request id already
* exist the backendSrv will try to cancel and replace the previous call with the
* new one.
*/
requestId?: string;
[key: string]: any;
/**
* Set to to true to not include call in query inspector
*/
silent?: boolean;
/**
* The data to send
*/
data?: any;
/**
* Query params
*/
params?: Record<string, any>;
/**
* Indicates whether or not cross-site Access-Control requests should be made using credentials such as cookies, authorization headers or TLS client certificates. Setting withCredentials has no effect on same-site requests.
* In addition, this flag is also used to indicate when cookies are to be ignored in the response.
*/
withCredentials?: boolean;
};
/**
* Response for fetch function in {@link BackendSrv}
*
* @public
*/
export interface FetchResponse<T = any> {
data: T;
readonly status: number;
readonly statusText: string;
readonly ok: boolean;
readonly headers: Headers;
readonly redirected: boolean;
readonly type: ResponseType;
readonly url: string;
readonly config: BackendSrvRequest;
}
/**
* Error type for fetch function in {@link BackendSrv}
*
* @public
*/
export interface FetchErrorDataProps {
message?: string;
status?: string;
error?: string | any;
}
/**
* Error type for fetch function in {@link BackendSrv}
*
* @public
*/
export interface FetchError<T extends FetchErrorDataProps = any> {
status: number;
statusText?: string;
data: T | string;
cancelled?: boolean;
isHandled?: boolean;
config: BackendSrvRequest;
}
/**
* Used to communicate via http(s) to a remote backend such as the Grafana backend,
* a datasource etc. The BackendSrv is using the {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}
* under the hood to handle all the communication.
......@@ -48,9 +120,8 @@ export type BackendSrvRequest = {
* use default values executing the request.
*
* @remarks
* By default Grafana will display an error message alert if the remote call fails. If you want
* to prevent this from happending you need to catch the error thrown by the BackendSrv and
* set the `isHandled = true` on the incoming error.
* By default, Grafana displays an error message alert if the remote call fails. To prevent this from
* happening `showErrorAlert = true` on the options object.
*
* @public
*/
......@@ -60,15 +131,26 @@ export interface BackendSrv {
post(url: string, data?: any): Promise<any>;
patch(url: string, data?: any): Promise<any>;
put(url: string, data?: any): Promise<any>;
/**
* @deprecated Use the fetch function instead. If you prefer to work with a promise
* call the toPromise() function on the Observable returned by fetch.
*/
request(options: BackendSrvRequest): Promise<any>;
/**
* @deprecated Use the fetch function instead
* Special function used to communicate with datasources that will emit core
* events that the Grafana QueryInspector and QueryEditor is listening for to be able
* to display datasource query information. Can be skipped by adding `option.silent`
* when initializing the request.
*/
datasourceRequest(options: BackendSrvRequest): Promise<any>;
/**
* Observable http request interface
*/
fetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>>;
}
let singletonInstance: BackendSrv;
......
......@@ -171,12 +171,11 @@ export class DataSourceWithBackend<
*/
async callHealthCheck(): Promise<HealthCheckResult> {
return getBackendSrv()
.get(`/api/datasources/${this.id}/health`)
.request({ method: 'GET', url: `/api/datasources/${this.id}/health`, showErrorAlert: false })
.then(v => {
return v as HealthCheckResult;
})
.catch(err => {
err.isHandled = true; // Avoid extra popup warning
return err.data as HealthCheckResult;
});
}
......
import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, throwError } from 'rxjs';
import { from, merge, MonoTypeOperatorFunction, Observable, Subject, throwError } from 'rxjs';
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
import { fromFetch } from 'rxjs/fetch';
import { BackendSrv as BackendService, BackendSrvRequest } from '@grafana/runtime';
import { BackendSrv as BackendService, BackendSrvRequest, FetchResponse, FetchError } from '@grafana/runtime';
import { AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import config from 'app/core/config';
import { DataSourceResponse } from 'app/types/events';
import { DashboardSearchHit } from 'app/features/search/types';
import { CoreEvents, DashboardDTO, FolderInfo, DashboardDataDTO, FolderDTO } from 'app/types';
import { FolderDTO } from 'app/types';
import { coreModule } from 'app/core/core_module';
import { ContextSrv, contextSrv } from './context_srv';
import { Emitter } from '../utils/emitter';
import { parseInitFromOptions, parseUrlFromOptions } from '../utils/fetch';
export interface DatasourceRequestOptions {
retry?: number;
method?: string;
requestId?: string;
timeout?: Promise<any>;
url?: string;
headers?: Record<string, any>;
silent?: boolean;
data?: Record<string, any>;
}
interface FetchResponseProps {
message?: string;
}
interface ErrorResponseProps extends FetchResponseProps {
status?: string;
error?: string | any;
}
export interface FetchResponse<T extends FetchResponseProps = any> extends DataSourceResponse<T> {}
interface SuccessResponse extends FetchResponseProps, Record<any, any> {}
interface DataSourceSuccessResponse<T extends {} = any> extends FetchResponse<T> {}
interface ErrorResponse<T extends ErrorResponseProps = any> {
status: number;
statusText?: string;
isHandled?: boolean;
data: T | string;
cancelled?: boolean;
}
enum CancellationType {
request,
dataSourceRequest,
}
const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id';
export interface BackendSrvDependencies {
......@@ -66,6 +26,8 @@ export class BackendSrv implements BackendService {
private inFlightRequests: Subject<string> = new Subject<string>();
private HTTP_REQUEST_CANCELED = -1;
private noBackendCache: boolean;
private inspectorStream: Subject<FetchResponse | FetchError> = new Subject<FetchResponse | FetchError>();
private dependencies: BackendSrvDependencies = {
fromFetch: fromFetch,
appEvents: appEvents,
......@@ -84,100 +46,33 @@ export class BackendSrv implements BackendService {
}
}
async get<T = any>(url: string, params?: any, requestId?: string): Promise<T> {
return await this.request({ method: 'GET', url, params, requestId });
}
async delete(url: string) {
return await this.request({ method: 'DELETE', url });
}
async post(url: string, data?: any) {
return await this.request({ method: 'POST', url, data });
}
async patch(url: string, data: any) {
return await this.request({ method: 'PATCH', url, data });
}
async put(url: string, data: any) {
return await this.request({ method: 'PUT', url, data });
}
withNoBackendCache(callback: any) {
this.noBackendCache = true;
return callback().finally(() => {
this.noBackendCache = false;
});
async request<T = any>(options: BackendSrvRequest): Promise<T> {
return this.fetch<T>(options)
.pipe(map((response: FetchResponse<T>) => response.data))
.toPromise();
}
requestErrorHandler = (err: ErrorResponse) => {
if (err.isHandled) {
return;
}
let data = err.data ?? { message: 'Unexpected error' };
if (typeof data === 'string') {
data = { message: data };
}
if (err.status === 422) {
this.dependencies.appEvents.emit(AppEvents.alertWarning, ['Validation failed', data.message]);
throw data;
}
if (data.message) {
let description = '';
let message = data.message;
if (message.length > 80) {
description = message;
message = 'Error';
}
this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [
message,
description,
]);
}
throw data;
};
async request(options: BackendSrvRequest): Promise<any> {
// A requestId is a unique identifier for a particular query.
// Every observable below has a takeUntil that subscribes to this.inFlightRequests and
// will cancel/unsubscribe that observable when a new datasourceRequest with the same requestId is made
fetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>> {
if (options.requestId) {
this.inFlightRequests.next(options.requestId);
}
options = this.parseRequestOptions(options, this.dependencies.contextSrv.user?.orgId);
options = this.parseRequestOptions(options);
const fromFetchStream = this.getFromFetchStream(options);
const failureStream = fromFetchStream.pipe(this.toFailureStream(options));
const fromFetchStream = this.getFromFetchStream<T>(options);
const failureStream = fromFetchStream.pipe(this.toFailureStream<T>(options));
const successStream = fromFetchStream.pipe(
filter(response => response.ok === true),
map(response => {
const fetchSuccessResponse: SuccessResponse = response.data;
return fetchSuccessResponse;
}),
tap(response => {
if (options.method !== 'GET' && response?.message && options.showSuccessAlert !== false) {
this.dependencies.appEvents.emit(AppEvents.alertSuccess, [response.message]);
}
this.showSuccessAlert(response);
this.inspectorStream.next(response);
})
);
return merge(successStream, failureStream)
.pipe(
catchError((err: ErrorResponse) => {
// this setTimeout hack enables any caller catching this err to set isHandled to true
setTimeout(() => this.requestErrorHandler(err), 50);
return throwError(err);
}),
this.handleStreamCancellation(options, CancellationType.request)
)
.toPromise();
return merge(successStream, failureStream).pipe(
catchError((err: FetchError) => throwError(this.processRequestError(options, err))),
this.handleStreamCancellation(options)
);
}
resolveCancelerIfExists(requestId: string) {
......@@ -189,198 +84,16 @@ export class BackendSrv implements BackendService {
}
async datasourceRequest(options: BackendSrvRequest): Promise<any> {
// A requestId is provided by the datasource as a unique identifier for a
// particular query. Every observable below has a takeUntil that subscribes to this.inFlightRequests and
// will cancel/unsubscribe that observable when a new datasourceRequest with the same requestId is made
if (options.requestId) {
this.inFlightRequests.next(options.requestId);
}
options = this.parseDataSourceRequestOptions(
options,
this.dependencies.contextSrv.user?.orgId,
this.noBackendCache
);
const fromFetchStream = this.getFromFetchStream(options);
const failureStream = fromFetchStream.pipe(this.toDataSourceRequestFailureStream(options));
const successStream = fromFetchStream.pipe(
filter(response => response.ok === true),
map(response => {
const fetchSuccessResponse: DataSourceSuccessResponse = { ...response };
return fetchSuccessResponse;
}),
tap(res => {
if (!options.silent) {
this.dependencies.appEvents.emit(CoreEvents.dsRequestResponse, res);
}
})
);
return merge(successStream, failureStream)
.pipe(
catchError((err: ErrorResponse) => {
// populate error obj on Internal Error
if (typeof err.data === 'string' && err.status === 500) {
err.data = {
error: err.statusText,
response: err.data,
};
}
// for Prometheus
if (err.data && !err.data.message && typeof err.data.error === 'string') {
err.data.message = err.data.error;
}
if (!options.silent) {
this.dependencies.appEvents.emit(CoreEvents.dsRequestError, err);
}
return throwError(err);
}),
this.handleStreamCancellation(options, CancellationType.dataSourceRequest)
)
.toPromise();
}
loginPing() {
return this.request({ url: '/api/login/ping', method: 'GET', retry: 1 });
}
search(query: any): Promise<DashboardSearchHit[]> {
return this.get('/api/search', query);
}
getDashboardBySlug(slug: string) {
return this.get(`/api/dashboards/db/${slug}`);
}
getDashboardByUid(uid: string) {
return this.get(`/api/dashboards/uid/${uid}`);
}
getFolderByUid(uid: string) {
return this.get<FolderDTO>(`/api/folders/${uid}`);
}
saveDashboard(
dashboard: DashboardDataDTO,
{ message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {}
) {
return this.post('/api/dashboards/db/', {
dashboard,
folderId,
overwrite,
message,
});
}
createFolder(payload: any) {
return this.post('/api/folders', payload);
}
deleteFolder(uid: string, showSuccessAlert: boolean) {
return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true });
}
deleteDashboard(uid: string, showSuccessAlert: boolean) {
return this.request({
method: 'DELETE',
url: `/api/dashboards/uid/${uid}`,
showSuccessAlert: showSuccessAlert === true,
});
}
deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
const tasks = [];
for (const folderUid of folderUids) {
tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true));
}
for (const dashboardUid of dashboardUids) {
tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true));
}
return this.executeInOrder(tasks);
}
moveDashboards(dashboardUids: string[], toFolder: FolderInfo) {
const tasks = [];
for (const uid of dashboardUids) {
tasks.push(this.createTask(this.moveDashboard.bind(this), true, uid, toFolder));
}
return this.executeInOrder(tasks).then((result: any) => {
return {
totalCount: result.length,
successCount: result.filter((res: any) => res.succeeded).length,
alreadyInFolderCount: result.filter((res: any) => res.alreadyInFolder).length,
};
});
}
private async moveDashboard(uid: string, toFolder: FolderInfo) {
const fullDash: DashboardDTO = await this.getDashboardByUid(uid);
if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {
return { alreadyInFolder: true };
}
const clone = fullDash.dashboard;
const options = {
folderId: toFolder.id,
overwrite: false,
};
try {
await this.saveDashboard(clone, options);
return { succeeded: true };
} catch (err) {
if (err.data?.status !== 'plugin-dashboard') {
return { succeeded: false };
}
err.isHandled = true;
options.overwrite = true;
try {
await this.saveDashboard(clone, options);
return { succeeded: true };
} catch (e) {
return { succeeded: false };
}
}
}
private createTask(fn: (...args: any[]) => Promise<any>, ignoreRejections: boolean, ...args: any[]) {
return async (result: any) => {
try {
const res = await fn(...args);
return Array.prototype.concat(result, [res]);
} catch (err) {
if (ignoreRejections) {
return result;
}
throw err;
}
};
return this.fetch(options).toPromise();
}
private executeInOrder(tasks: any[]) {
return tasks.reduce((acc, task) => {
return Promise.resolve(acc).then(task);
}, []);
}
private parseRequestOptions(options: BackendSrvRequest): BackendSrvRequest {
const orgId = this.dependencies.contextSrv.user?.orgId;
private parseRequestOptions = (options: BackendSrvRequest, orgId?: number): BackendSrvRequest => {
// init retry counter
options.retry = options.retry ?? 0;
const requestIsLocal = !options.url.match(/^http/);
if (requestIsLocal) {
if (isLocalUrl(options.url)) {
if (orgId) {
options.headers = options.headers ?? {};
options.headers['X-Grafana-Org-Id'] = orgId;
......@@ -390,59 +103,40 @@ export class BackendSrv implements BackendService {
options.url = options.url.substring(1);
}
if (options.url.endsWith('/')) {
options.url = options.url.slice(0, -1);
}
}
return options;
};
private parseDataSourceRequestOptions = (
options: BackendSrvRequest,
orgId?: number,
noBackendCache?: boolean
): BackendSrvRequest => {
options.retry = options.retry ?? 0;
const requestIsLocal = !options.url.match(/^http/);
if (requestIsLocal) {
if (orgId) {
options.headers = options.headers || {};
options.headers['X-Grafana-Org-Id'] = orgId;
}
if (options.url.startsWith('/')) {
options.url = options.url.substring(1);
}
// if (options.url.endsWith('/')) {
// options.url = options.url.slice(0, -1);
// }
if (options.headers?.Authorization) {
options.headers['X-DS-Authorization'] = options.headers.Authorization;
delete options.headers.Authorization;
}
if (noBackendCache) {
if (this.noBackendCache) {
options.headers['X-Grafana-NoCache'] = 'true';
}
}
return options;
};
}
private getFromFetchStream = (options: BackendSrvRequest) => {
private getFromFetchStream<T>(options: BackendSrvRequest): Observable<FetchResponse<T>> {
const url = parseUrlFromOptions(options);
const init = parseInitFromOptions(options);
return this.dependencies.fromFetch(url, init).pipe(
mergeMap(async response => {
const { status, statusText, ok, headers, url, type, redirected } = response;
const textData = await response.text(); // this could be just a string, prometheus requests for instance
let data;
let data: T;
try {
data = JSON.parse(textData); // majority of the requests this will be something that can be parsed
} catch {
data = textData;
data = textData as any;
}
const fetchResponse: FetchResponse = {
const fetchResponse: FetchResponse<T> = {
status,
statusText,
ok,
......@@ -457,121 +151,226 @@ export class BackendSrv implements BackendService {
}),
share() // sharing this so we can split into success and failure and then merge back
);
};
}
private toFailureStream = (options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse> => inputStream =>
inputStream.pipe(
filter(response => response.ok === false),
mergeMap(response => {
const { status, statusText, data } = response;
const fetchErrorResponse: ErrorResponse = { status, statusText, data };
return throwError(fetchErrorResponse);
}),
retryWhen((attempts: Observable<any>) =>
attempts.pipe(
mergeMap((error, i) => {
const firstAttempt = i === 0 && options.retry === 0;
if (error.status === 401 && this.dependencies.contextSrv.user.isSignedIn && firstAttempt) {
return from(this.loginPing()).pipe(
catchError(err => {
if (err.status === 401) {
this.dependencies.logout();
return throwError(err);
}
return throwError(err);
})
);
}
return throwError(error);
})
)
)
);
private toFailureStream<T>(options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse<T>> {
const { isSignedIn } = this.dependencies.contextSrv.user;
private toDataSourceRequestFailureStream = (
options: BackendSrvRequest
): MonoTypeOperatorFunction<FetchResponse> => inputStream =>
inputStream.pipe(
filter(response => response.ok === false),
mergeMap(response => {
const { status, statusText, data } = response;
const fetchErrorResponse: ErrorResponse = { status, statusText, data };
return throwError(fetchErrorResponse);
}),
retryWhen((attempts: Observable<any>) =>
attempts.pipe(
mergeMap((error, i) => {
const requestIsLocal = !options.url.match(/^http/);
const firstAttempt = i === 0 && options.retry === 0;
// First retry, if loginPing returns 401 this retry sequence will abort with throwError and user is logged out
if (requestIsLocal && firstAttempt && error.status === 401) {
return from(this.loginPing()).pipe(
catchError(err => {
if (err.status === 401) {
this.dependencies.logout();
return inputStream =>
inputStream.pipe(
filter(response => response.ok === false),
mergeMap(response => {
const { status, statusText, data } = response;
const fetchErrorResponse: FetchError = { status, statusText, data, config: options };
return throwError(fetchErrorResponse);
}),
retryWhen((attempts: Observable<any>) =>
attempts.pipe(
mergeMap((error, i) => {
const firstAttempt = i === 0 && options.retry === 0;
if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) {
return from(this.loginPing()).pipe(
catchError(err => {
if (err.status === 401) {
this.dependencies.logout();
return throwError(err);
}
return throwError(err);
}
return throwError(err);
})
);
}
return throwError(error);
})
)
)
);
})
);
}
private handleStreamCancellation = (
options: BackendSrvRequest,
resultType: CancellationType
): MonoTypeOperatorFunction<FetchResponse | DataSourceSuccessResponse | SuccessResponse> => inputStream =>
inputStream.pipe(
takeUntil(
this.inFlightRequests.pipe(
filter(requestId => {
let cancelRequest = false;
if (options && options.requestId && options.requestId === requestId) {
// when a new requestId is started it will be published to inFlightRequests
// if a previous long running request that hasn't finished yet has the same requestId
// we need to cancel that request
cancelRequest = true;
}
if (requestId === CANCEL_ALL_REQUESTS_REQUEST_ID) {
cancelRequest = true;
}
return cancelRequest;
})
return throwError(error);
})
)
)
),
// when a request is cancelled by takeUntil it will complete without emitting anything so we use throwIfEmpty to identify this case
// in throwIfEmpty we'll then throw an cancelled error and then we'll return the correct result in the catchError or rethrow
throwIfEmpty(() => ({
cancelled: true,
})),
catchError(err => {
if (!err.cancelled) {
return throwError(err);
}
);
}
showApplicationErrorAlert(err: FetchError) {}
showSuccessAlert<T>(response: FetchResponse<T>) {
const { config } = response;
if (config.showSuccessAlert === false) {
return;
}
// is showSuccessAlert is undefined we only show alerts non GET request, non data query and local api requests
if (
config.showSuccessAlert === undefined &&
(config.method === 'GET' || isDataQuery(config.url) || !isLocalUrl(config.url))
) {
return;
}
const data: { message: string } = response.data as any;
if (data?.message) {
this.dependencies.appEvents.emit(AppEvents.alertSuccess, [data.message]);
}
}
showErrorAlert<T>(config: BackendSrvRequest, err: FetchError) {
if (config.showErrorAlert === false) {
return;
}
// is showErrorAlert is undefined we only show alerts non data query and local api requests
if (config.showErrorAlert === undefined || isDataQuery(config.url) || !isLocalUrl(config.url)) {
return;
}
let description = '';
let message = err.data.message;
if (message.length > 80) {
description = message;
message = 'Error';
}
// Validation
if (err.status === 422) {
message = 'Validation failed';
}
this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [
message,
description,
]);
}
processRequestError(options: BackendSrvRequest, err: FetchError): FetchError {
err.data = err.data ?? { message: 'Unexpected error' };
if (typeof err.data === 'string') {
err.data = {
error: err.statusText,
response: err.data,
message: err.data,
};
}
// If no message but got error string, copy to message prop
if (err.data && !err.data.message && typeof err.data.error === 'string') {
err.data.message = err.data.error;
}
if (resultType === CancellationType.dataSourceRequest) {
return of({
data: [],
status: this.HTTP_REQUEST_CANCELED,
statusText: 'Request was aborted',
config: options,
});
// check if we should show an error alert
if (err.data.message) {
setTimeout(() => {
if (!err.isHandled) {
this.showErrorAlert(options, err);
}
}, 50);
}
return of([]);
})
);
this.inspectorStream.next(err);
return err;
}
private handleStreamCancellation(options: BackendSrvRequest): MonoTypeOperatorFunction<FetchResponse<any>> {
return inputStream =>
inputStream.pipe(
takeUntil(
this.inFlightRequests.pipe(
filter(requestId => {
let cancelRequest = false;
if (options && options.requestId && options.requestId === requestId) {
// when a new requestId is started it will be published to inFlightRequests
// if a previous long running request that hasn't finished yet has the same requestId
// we need to cancel that request
cancelRequest = true;
}
if (requestId === CANCEL_ALL_REQUESTS_REQUEST_ID) {
cancelRequest = true;
}
return cancelRequest;
})
)
),
// when a request is cancelled by takeUntil it will complete without emitting anything so we use throwIfEmpty to identify this case
// in throwIfEmpty we'll then throw an cancelled error and then we'll return the correct result in the catchError or rethrow
throwIfEmpty(() => ({
cancelled: true,
data: null,
status: this.HTTP_REQUEST_CANCELED,
statusText: 'Request was aborted',
config: options,
}))
);
}
getInspectorStream(): Observable<FetchResponse<any> | FetchError> {
return this.inspectorStream;
}
async get<T = any>(url: string, params?: any, requestId?: string): Promise<T> {
return await this.request({ method: 'GET', url, params, requestId });
}
async delete(url: string) {
return await this.request({ method: 'DELETE', url });
}
async post(url: string, data?: any) {
return await this.request({ method: 'POST', url, data });
}
async patch(url: string, data: any) {
return await this.request({ method: 'PATCH', url, data });
}
async put(url: string, data: any) {
return await this.request({ method: 'PUT', url, data });
}
withNoBackendCache(callback: any) {
this.noBackendCache = true;
return callback().finally(() => {
this.noBackendCache = false;
});
}
loginPing() {
return this.request({ url: '/api/login/ping', method: 'GET', retry: 1 });
}
search(query: any): Promise<DashboardSearchHit[]> {
return this.get('/api/search', query);
}
getDashboardBySlug(slug: string) {
return this.get(`/api/dashboards/db/${slug}`);
}
getDashboardByUid(uid: string) {
return this.get(`/api/dashboards/uid/${uid}`);
}
getFolderByUid(uid: string) {
return this.get<FolderDTO>(`/api/folders/${uid}`);
}
}
function isDataQuery(url: string): boolean {
if (
url.indexOf('api/datasources/proxy') !== -1 ||
url.indexOf('api/tsdb/query') !== -1 ||
url.indexOf('api/ds/query') !== -1
) {
return true;
}
return false;
}
function isLocalUrl(url: string) {
return !url.match(/^http/);
}
coreModule.factory('backendSrv', () => backendSrv);
......
......@@ -3,10 +3,9 @@ import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AppEvents } from '@grafana/data';
import { BackendSrv, getBackendSrv } from '../services/backend_srv';
import { BackendSrv } from '../services/backend_srv';
import { Emitter } from '../utils/emitter';
import { ContextSrv, User } from '../services/context_srv';
import { CoreEvents } from '../../types';
import { describe, expect } from '../../../test/lib/common';
const getTestContext = (overides?: object) => {
......@@ -47,7 +46,6 @@ const getTestContext = (overides?: object) => {
} as any) as ContextSrv;
const logoutMock = jest.fn();
const parseRequestOptionsMock = jest.fn().mockImplementation(options => options);
const parseDataSourceRequestOptionsMock = jest.fn().mockImplementation(options => options);
const backendSrv = new BackendSrv({
fromFetch: fromFetchMock,
......@@ -57,7 +55,6 @@ const getTestContext = (overides?: object) => {
});
backendSrv['parseRequestOptions'] = parseRequestOptionsMock;
backendSrv['parseDataSourceRequestOptions'] = parseDataSourceRequestOptionsMock;
const expectCallChain = (options: any) => {
expect(fromFetchMock).toHaveBeenCalledTimes(1);
......@@ -65,13 +62,7 @@ const getTestContext = (overides?: object) => {
const expectRequestCallChain = (options: any) => {
expect(parseRequestOptionsMock).toHaveBeenCalledTimes(1);
expect(parseRequestOptionsMock).toHaveBeenCalledWith(options, 1337);
expectCallChain(options);
};
const expectDataSourceRequestCallChain = (options: any) => {
expect(parseDataSourceRequestOptionsMock).toHaveBeenCalledTimes(1);
expect(parseDataSourceRequestOptionsMock).toHaveBeenCalledWith(options, 1337, undefined);
expect(parseRequestOptionsMock).toHaveBeenCalledWith(options);
expectCallChain(options);
};
......@@ -83,34 +74,13 @@ const getTestContext = (overides?: object) => {
textMock,
logoutMock,
parseRequestOptionsMock,
parseDataSourceRequestOptionsMock,
expectRequestCallChain,
expectDataSourceRequestCallChain,
};
};
describe('backendSrv', () => {
describe('parseRequestOptions', () => {
it.each`
retry | url | orgId | expected
${undefined} | ${'http://localhost:3000/api/dashboard'} | ${undefined} | ${{ retry: 0, url: 'http://localhost:3000/api/dashboard' }}
${1} | ${'http://localhost:3000/api/dashboard'} | ${1} | ${{ retry: 1, url: 'http://localhost:3000/api/dashboard' }}
${undefined} | ${'api/dashboard'} | ${undefined} | ${{ retry: 0, url: 'api/dashboard' }}
${undefined} | ${'/api/dashboard'} | ${undefined} | ${{ retry: 0, url: 'api/dashboard' }}
${undefined} | ${'/api/dashboard/'} | ${undefined} | ${{ retry: 0, url: 'api/dashboard' }}
${1} | ${'/api/dashboard/'} | ${undefined} | ${{ retry: 1, url: 'api/dashboard' }}
${undefined} | ${'/api/dashboard/'} | ${1} | ${{ retry: 0, url: 'api/dashboard', headers: { 'X-Grafana-Org-Id': 1 } }}
${1} | ${'/api/dashboard/'} | ${1} | ${{ retry: 1, url: 'api/dashboard', headers: { 'X-Grafana-Org-Id': 1 } }}
`(
"when called with retry: '$retry', url: '$url' and orgId: '$orgId' then result should be '$expected'",
({ retry, url, orgId, expected }) => {
expect(getBackendSrv()['parseRequestOptions']({ retry, url }, orgId)).toEqual(expected);
}
);
});
describe('parseDataSourceRequestOptions', () => {
it.each`
retry | url | headers | orgId | noBackendCache | expected
${undefined} | ${'http://localhost:3000/api/dashboard'} | ${undefined} | ${undefined} | ${undefined} | ${{ retry: 0, url: 'http://localhost:3000/api/dashboard' }}
${1} | ${'http://localhost:3000/api/dashboard'} | ${{ Authorization: 'Some Auth' }} | ${1} | ${true} | ${{ retry: 1, url: 'http://localhost:3000/api/dashboard', headers: { Authorization: 'Some Auth' } }}
......@@ -125,11 +95,23 @@ describe('backendSrv', () => {
${1} | ${'/api/dashboard/'} | ${{ Authorization: 'Some Auth' }} | ${1} | ${undefined} | ${{ retry: 1, url: 'api/dashboard/', headers: { 'X-DS-Authorization': 'Some Auth', 'X-Grafana-Org-Id': 1 } }}
${1} | ${'/api/dashboard/'} | ${{ Authorization: 'Some Auth' }} | ${1} | ${true} | ${{ retry: 1, url: 'api/dashboard/', headers: { 'X-DS-Authorization': 'Some Auth', 'X-Grafana-Org-Id': 1, 'X-Grafana-NoCache': 'true' } }}
`(
"when called with retry: '$retry', url: '$url', headers: '$headers', orgId: '$orgId' and noBackendCache: '$noBackendCache' then result should be '$expected'",
"when called with retry: '$retry', url: '$url' and orgId: '$orgId' then result should be '$expected'",
({ retry, url, headers, orgId, noBackendCache, expected }) => {
expect(
getBackendSrv()['parseDataSourceRequestOptions']({ retry, url, headers }, orgId, noBackendCache)
).toEqual(expected);
const srv = new BackendSrv({
contextSrv: {
user: {
orgId: orgId,
},
},
} as any);
if (noBackendCache) {
srv.withNoBackendCache(async () => {
expect(srv['parseRequestOptions']({ retry, url, headers })).toEqual(expected);
});
} else {
expect(srv['parseRequestOptions']({ retry, url, headers })).toEqual(expected);
}
}
);
});
......@@ -142,6 +124,7 @@ describe('backendSrv', () => {
});
const url = '/api/dashboard/';
const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: false });
expect(result).toEqual({ message: 'A message' });
expect(appEventsMock.emit).not.toHaveBeenCalled();
expectRequestCallChain({ url, method: 'DELETE', showSuccessAlert: false });
......@@ -155,6 +138,7 @@ describe('backendSrv', () => {
});
const url = '/api/dashboard/';
const result = await backendSrv.request({ url, method: 'DELETE', showSuccessAlert: true });
expect(result).toEqual({ message: 'A message' });
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertSuccess, ['A message']);
......@@ -162,61 +146,6 @@ describe('backendSrv', () => {
});
});
describe('when called with the same requestId twice', () => {
it('then it should cancel the first call and the first call should be unsubscribed', async () => {
const url = '/api/dashboard/';
const { backendSrv, fromFetchMock } = getTestContext({ url });
const unsubscribe = jest.fn();
const slowData = { message: 'Slow Request' };
const slowFetch = new Observable(subscriber => {
subscriber.next({
ok: true,
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)),
headers: {
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
url,
});
return unsubscribe;
}).pipe(delay(10000));
const fastData = { message: 'Fast Request' };
const fastFetch = of({
ok: true,
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)),
headers: {
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
url,
});
fromFetchMock.mockImplementationOnce(() => slowFetch);
fromFetchMock.mockImplementation(() => fastFetch);
const options = {
url,
method: 'GET',
requestId: 'A',
};
const slowRequest = backendSrv.request(options);
const fastResponse = await backendSrv.request(options);
expect(fastResponse).toEqual({ message: 'Fast Request' });
const result = await slowRequest;
expect(result).toEqual([]);
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('when making an unsuccessful call and conditions for retry are favorable and loginPing does not throw', () => {
it('then it should retry', async () => {
jest.useFakeTimers();
......@@ -228,9 +157,11 @@ describe('backendSrv', () => {
data: { message: 'UnAuthorized' },
url,
});
backendSrv.loginPing = jest
.fn()
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', data: { message: 'Ok' } });
await backendSrv
.request({ url, method: 'GET', retry: 0 })
.catch(error => {
......@@ -260,10 +191,12 @@ describe('backendSrv', () => {
statusText: 'UnAuthorized',
data: { message: 'UnAuthorized' },
});
backendSrv.loginPing = jest
.fn()
.mockRejectedValue({ status: 403, statusText: 'Forbidden', data: { message: 'Forbidden' } });
const url = '/api/dashboard/';
await backendSrv
.request({ url, method: 'GET', retry: 0 })
.catch(error => {
......@@ -294,6 +227,7 @@ describe('backendSrv', () => {
data: { message: 'Unprocessable Entity' },
});
const url = '/api/dashboard/';
await backendSrv
.request({ url, method: 'GET' })
.catch(error => {
......@@ -326,6 +260,7 @@ describe('backendSrv', () => {
data: { message: 'Not found' },
});
const url = '/api/dashboard/';
await backendSrv.request({ url, method: 'GET' }).catch(error => {
expect(error.status).toBe(404);
expect(error.statusText).toBe('Not found');
......@@ -345,9 +280,10 @@ describe('backendSrv', () => {
describe('when making a successful call and silent is true', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const { backendSrv, appEventsMock, expectRequestCallChain } = getTestContext({ url });
const options = { url, method: 'GET', silent: true };
const result = await backendSrv.datasourceRequest(options);
expect(result).toEqual({
data: { test: 'hello world' },
ok: true,
......@@ -358,16 +294,23 @@ describe('backendSrv', () => {
url,
config: options,
});
expect(appEventsMock.emit).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', silent: true });
expectRequestCallChain({ url, method: 'GET', silent: true });
});
});
describe('when making a successful call and silent is not defined', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const { backendSrv, expectRequestCallChain } = getTestContext({ url });
const options = { url, method: 'GET' };
let inspectorPacket: any = null;
backendSrv.getInspectorStream().subscribe({
next: rsp => (inspectorPacket = rsp),
});
const result = await backendSrv.datasourceRequest(options);
const expectedResult = {
data: { test: 'hello world' },
......@@ -381,9 +324,8 @@ describe('backendSrv', () => {
};
expect(result).toEqual(expectedResult);
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.dsRequestResponse, expectedResult);
expectDataSourceRequestCallChain({ url, method: 'GET' });
expect(inspectorPacket).toEqual(expectedResult);
expectRequestCallChain({ url, method: 'GET' });
});
});
......@@ -405,6 +347,7 @@ describe('backendSrv', () => {
});
return unsubscribe;
}).pipe(delay(10000));
const fastData = { message: 'Fast Request' };
const fastFetch = of({
ok: true,
......@@ -415,118 +358,119 @@ describe('backendSrv', () => {
type: 'basic',
url,
});
fromFetchMock.mockImplementationOnce(() => slowFetch);
fromFetchMock.mockImplementation(() => fastFetch);
const options = {
url,
method: 'GET',
requestId: 'A',
};
const slowRequest = backendSrv.datasourceRequest(options);
const fastResponse = await backendSrv.datasourceRequest(options);
let slowError: any = null;
backendSrv.request(options).catch(err => {
slowError = err;
});
const fastResponse = await backendSrv.request(options);
expect(fastResponse).toEqual({
data: { message: 'Fast Request' },
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url: '/api/dashboard/',
config: options,
message: 'Fast Request',
});
const result = await slowRequest;
expect(result).toEqual({
data: [],
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(slowError).toEqual({
cancelled: true,
data: null,
status: -1,
statusText: 'Request was aborted',
config: options,
});
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('when making an unsuccessful call and conditions for retry are favorable and loginPing does not throw', () => {
it('then it should retry', async () => {
const { backendSrv, appEventsMock, logoutMock, expectDataSourceRequestCallChain } = getTestContext({
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
ok: false,
status: 401,
statusText: 'UnAuthorized',
data: { message: 'UnAuthorized' },
});
backendSrv.loginPing = jest
.fn()
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', data: { message: 'Ok' } });
const url = '/api/dashboard/';
let inspectorPacket: any = null;
backendSrv.getInspectorStream().subscribe({
next: rsp => (inspectorPacket = rsp),
});
await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).catch(error => {
expect(error.status).toBe(401);
expect(error.statusText).toBe('UnAuthorized');
expect(error.data).toEqual({ message: 'UnAuthorized' });
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.dsRequestError, {
data: { message: 'UnAuthorized' },
status: 401,
statusText: 'UnAuthorized',
});
expect(inspectorPacket).toBe(error);
expect(backendSrv.loginPing).toHaveBeenCalledTimes(1);
expect(logoutMock).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', retry: 0 });
expectRequestCallChain({ url, method: 'GET', retry: 0 });
});
});
});
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
it('then it throw error', async () => {
const { backendSrv, appEventsMock, logoutMock, expectDataSourceRequestCallChain } = getTestContext({
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
ok: false,
status: 401,
statusText: 'UnAuthorized',
data: { message: 'UnAuthorized' },
});
const options = {
url: '/api/dashboard/',
method: 'GET',
retry: 0,
};
backendSrv.loginPing = jest
.fn()
.mockRejectedValue({ status: 403, statusText: 'Forbidden', data: { message: 'Forbidden' } });
const url = '/api/dashboard/';
await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).catch(error => {
await backendSrv.datasourceRequest(options).catch(error => {
expect(error.status).toBe(403);
expect(error.statusText).toBe('Forbidden');
expect(error.data).toEqual({ message: 'Forbidden' });
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.dsRequestError, {
data: { message: 'Forbidden' },
status: 403,
statusText: 'Forbidden',
});
expect(backendSrv.loginPing).toHaveBeenCalledTimes(1);
expect(logoutMock).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', retry: 0 });
expectRequestCallChain(options);
});
});
});
describe('when making an Internal Error call', () => {
it('then it should throw cancelled error', async () => {
const { backendSrv, appEventsMock, logoutMock, expectDataSourceRequestCallChain } = getTestContext({
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
ok: false,
status: 500,
statusText: 'Internal Server Error',
data: 'Internal Server Error',
});
const url = '/api/dashboard/';
await backendSrv.datasourceRequest({ url, method: 'GET' }).catch(error => {
const options = {
url: '/api/dashboard/',
method: 'GET',
};
await backendSrv.datasourceRequest(options).catch(error => {
expect(error).toEqual({
status: 500,
statusText: 'Internal Server Error',
data: {
error: 'Internal Server Error',
response: 'Internal Server Error',
message: 'Internal Server Error',
},
});
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.dsRequestError, {
status: 500,
statusText: 'Internal Server Error',
config: options,
data: {
error: 'Internal Server Error',
response: 'Internal Server Error',
......@@ -534,40 +478,42 @@ describe('backendSrv', () => {
},
});
expect(logoutMock).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET' });
expectRequestCallChain(options);
});
});
});
describe('when formatting prometheus error', () => {
it('then it should throw cancelled error', async () => {
const { backendSrv, appEventsMock, logoutMock, expectDataSourceRequestCallChain } = getTestContext({
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
ok: false,
status: 403,
statusText: 'Forbidden',
data: { error: 'Forbidden' },
});
const url = '/api/dashboard/';
await backendSrv.datasourceRequest({ url, method: 'GET' }).catch(error => {
const options = {
url: '/api/dashboard/',
method: 'GET',
};
let inspectorPacket: any = null;
backendSrv.getInspectorStream().subscribe({
next: rsp => (inspectorPacket = rsp),
});
await backendSrv.datasourceRequest(options).catch(error => {
expect(error).toEqual({
status: 403,
statusText: 'Forbidden',
config: options,
data: {
error: 'Forbidden',
message: 'Forbidden',
},
});
expect(appEventsMock.emit).toHaveBeenCalledTimes(1);
expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.dsRequestError, {
status: 403,
statusText: 'Forbidden',
data: {
error: 'Forbidden',
message: 'Forbidden',
},
});
expect(inspectorPacket).toEqual(error);
expect(logoutMock).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET' });
expectRequestCallChain(options);
});
});
});
......@@ -575,23 +521,7 @@ describe('backendSrv', () => {
describe('cancelAllInFlightRequests', () => {
describe('when called with 2 separate requests and then cancelAllInFlightRequests is called', () => {
enum RequestType {
request,
dataSourceRequest,
}
const url = '/api/dashboard/';
const options = {
url,
method: 'GET',
};
const dataSourceRequestResult = {
data: ([] as unknown[]) as any[],
status: -1,
statusText: 'Request was aborted',
config: options,
};
const getRequestObservable = (message: string, unsubscribe: any) =>
new Observable(subscriber => {
......@@ -612,47 +542,37 @@ describe('backendSrv', () => {
return unsubscribe;
}).pipe(delay(10000));
it.each`
firstRequestType | secondRequestType | firstRequestResult | secondRequestResult
${RequestType.request} | ${RequestType.request} | ${[]} | ${[]}
${RequestType.dataSourceRequest} | ${RequestType.dataSourceRequest} | ${dataSourceRequestResult} | ${dataSourceRequestResult}
${RequestType.request} | ${RequestType.dataSourceRequest} | ${[]} | ${dataSourceRequestResult}
${RequestType.dataSourceRequest} | ${RequestType.request} | ${dataSourceRequestResult} | ${[]}
`(
'then it both requests should be cancelled and unsubscribed',
async ({ firstRequestType, secondRequestType, firstRequestResult, secondRequestResult }) => {
const unsubscribe = jest.fn();
const { backendSrv, fromFetchMock } = getTestContext({ url });
const firstObservable = getRequestObservable('First', unsubscribe);
const secondObservable = getRequestObservable('Second', unsubscribe);
fromFetchMock.mockImplementationOnce(() => firstObservable);
fromFetchMock.mockImplementation(() => secondObservable);
const options = {
url,
method: 'GET',
};
it('then it both requests should be cancelled and unsubscribed', async () => {
const unsubscribe = jest.fn();
const { backendSrv, fromFetchMock } = getTestContext({ url });
const firstObservable = getRequestObservable('First', unsubscribe);
const secondObservable = getRequestObservable('Second', unsubscribe);
const firstRequest =
firstRequestType === RequestType.request
? backendSrv.request(options)
: backendSrv.datasourceRequest(options);
fromFetchMock.mockImplementationOnce(() => firstObservable);
fromFetchMock.mockImplementation(() => secondObservable);
const secondRequest =
secondRequestType === RequestType.request
? backendSrv.request(options)
: backendSrv.datasourceRequest(options);
const options = {
url,
method: 'GET',
};
const firstRequest = backendSrv.request(options);
const secondRequest = backendSrv.request(options);
backendSrv.cancelAllInFlightRequests();
backendSrv.cancelAllInFlightRequests();
const result = await Promise.all([firstRequest, secondRequest]);
let catchedError: any = null;
expect(result[0]).toEqual(firstRequestResult);
expect(result[1]).toEqual(secondRequestResult);
expect(unsubscribe).toHaveBeenCalledTimes(2);
try {
await Promise.all([firstRequest, secondRequest]);
} catch (err) {
catchedError = err;
}
);
expect(catchedError.cancelled).toEqual(true);
expect(catchedError.statusText).toEqual('Request was aborted');
expect(unsubscribe).toHaveBeenCalledTimes(2);
});
});
});
});
......@@ -38,11 +38,12 @@ export function loadAdminUserPage(userId: number): ThunkResult<void> {
dispatch(userAdminPageLoadedAction(true));
} catch (error) {
console.log(error);
error.isHandled = true;
const userError = {
title: error.data.message,
body: error.data.error,
};
dispatch(userAdminPageFailedAction(userError));
}
};
......
......@@ -6,12 +6,12 @@ import { selectors } from '@grafana/e2e-selectors';
import { appEvents, contextSrv, coreModule } from 'app/core/core';
import { DashboardModel } from '../../state/DashboardModel';
import { getConfig } from 'app/core/config';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSrv } from '../../services/DashboardSrv';
import { CoreEvents } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { AppEvents, locationUtil, TimeZone } from '@grafana/data';
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
export class SettingsCtrl {
dashboard: DashboardModel;
......@@ -229,7 +229,7 @@ export class SettingsCtrl {
deleteDashboardConfirmed() {
promiseToDigest(this.$scope)(
backendSrv.deleteDashboard(this.dashboard.uid, false).then(() => {
deleteDashboard(this.dashboard.uid, false).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
this.$location.url('/');
})
......
......@@ -8,6 +8,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { ValidationSrv } from 'app/features/manage-dashboards';
import { ContextSrv } from 'app/core/services/context_srv';
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
import { createFolder } from 'app/features/manage-dashboards/state/actions';
export class FolderPickerCtrl {
initialTitle: string;
......@@ -111,7 +112,7 @@ export class FolderPickerCtrl {
}
return promiseToDigest(this.$scope)(
backendSrv.createFolder({ title: this.newFolderName }).then((result: { title: string; id: number }) => {
createFolder({ title: this.newFolderName }).then((result: { title: string; id: number }) => {
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
this.closeCreateFolder();
......
......@@ -5,12 +5,13 @@ import { AppEvents, PanelEvents, DataFrame } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { CoreEvents } from 'app/types';
import { PanelModel } from 'app/features/dashboard/state';
import { getPanelInspectorStyles } from './styles';
import { supportsDataQuery } from '../PanelEditor/utils';
import { config } from '@grafana/runtime';
import { css } from 'emotion';
import { Unsubscribable } from 'rxjs';
import { backendSrv } from 'app/core/services/backend_srv';
interface DsQuery {
isLoading: boolean;
......@@ -40,6 +41,7 @@ interface State {
export class QueryInspector extends PureComponent<Props, State> {
formattedJson: any;
clipboard: any;
subscription?: Unsubscribable;
constructor(props: Props) {
super(props);
......@@ -56,8 +58,10 @@ export class QueryInspector extends PureComponent<Props, State> {
}
componentDidMount() {
appEvents.on(CoreEvents.dsRequestResponse, this.onDataSourceResponse);
appEvents.on(CoreEvents.dsRequestError, this.onRequestError);
this.subscription = backendSrv.getInspectorStream().subscribe({
next: response => this.onDataSourceResponse(response),
});
this.props.panel.events.on(PanelEvents.refresh, this.onPanelRefresh);
this.updateQueryList();
}
......@@ -74,12 +78,16 @@ export class QueryInspector extends PureComponent<Props, State> {
updateQueryList() {
const { data } = this.props;
const executedQueries: ExecutedQueryInfo[] = [];
if (data?.length) {
let last: ExecutedQueryInfo | undefined = undefined;
data.forEach((frame, idx) => {
const query = frame.meta?.executedQueryString;
if (query) {
const refId = frame.refId || '?';
if (last?.refId === refId) {
last.frames++;
last.rows += frame.length;
......@@ -95,6 +103,7 @@ export class QueryInspector extends PureComponent<Props, State> {
}
});
}
this.setState({ executedQueries });
}
......@@ -105,23 +114,11 @@ export class QueryInspector extends PureComponent<Props, State> {
componentWillUnmount() {
const { panel } = this.props;
appEvents.off(CoreEvents.dsRequestResponse, this.onDataSourceResponse);
appEvents.on(CoreEvents.dsRequestError, this.onRequestError);
panel.events.off(PanelEvents.refresh, this.onPanelRefresh);
}
handleMocking(response: any) {
const { mockedResponse } = this.state;
let mockedData;
try {
mockedData = JSON.parse(mockedResponse);
} catch (err) {
appEvents.emit(AppEvents.alertError, ['R: Failed to parse mocked response']);
return;
if (this.subscription) {
this.subscription.unsubscribe();
}
response.data = mockedData;
panel.events.off(PanelEvents.refresh, this.onPanelRefresh);
}
onPanelRefresh = () => {
......@@ -134,13 +131,9 @@ export class QueryInspector extends PureComponent<Props, State> {
}));
};
onRequestError = (err: any) => {
this.onDataSourceResponse(err);
};
onDataSourceResponse = (response: any = {}) => {
if (this.state.isMocking) {
this.handleMocking(response);
onDataSourceResponse(response: any) {
// ignore silent requests
if (response.config?.silent) {
return;
}
......@@ -186,7 +179,7 @@ export class QueryInspector extends PureComponent<Props, State> {
response: response,
},
}));
};
}
setFormattedJson = (formattedJson: any) => {
this.formattedJson = formattedJson;
......
......@@ -7,11 +7,11 @@ import { CoreEvents, StoreState } from 'app/types';
import appEvents from 'app/core/app_events';
import { updateLocation } from 'app/core/reducers/location';
import { DashboardModel } from 'app/features/dashboard/state';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions';
const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => {
const folderId = options.folderId >= 0 ? options.folderId : dashboard.meta.folderId || saveModel.folderId;
return await getBackendSrv().saveDashboard(saveModel, { ...options, folderId });
return await saveDashboardApiCall({ ...options, folderId, dashboard: saveModel });
};
export const useDashboardSave = (dashboard: DashboardModel) => {
......
......@@ -4,8 +4,9 @@ import { DashboardModel } from '../state/DashboardModel';
import { removePanel } from '../utils/panel';
import { CoreEvents, DashboardMeta } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { backendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import { promiseToDigest } from '../../../core/utils/promiseToDigest';
import { saveDashboard } from 'app/features/manage-dashboards/state/actions';
export class DashboardSrv {
dashboard: DashboardModel;
......@@ -34,7 +35,8 @@ export class DashboardSrv {
saveJSONDashboard(json: string) {
const parsedJson = JSON.parse(json);
return getBackendSrv().saveDashboard(parsedJson, {
return saveDashboard({
dashboard: parsedJson,
folderId: this.dashboard.meta.folderId || parsedJson.folderId,
});
}
......
......@@ -115,13 +115,14 @@ export function runRequest(datasource: DataSourceApi, request: DataQueryRequest)
return state.panelData;
}),
// handle errors
catchError(err =>
of({
catchError(err => {
console.log('runRequest.catchError', err);
return of({
...state.panelData,
state: LoadingState.Error,
error: toDataQueryError(err),
})
),
});
}),
tap(emitDataRequestEvent(datasource)),
// finalize is triggered when subscriber unsubscribes
// This makes sure any still running network requests are cancelled
......
......@@ -33,7 +33,7 @@ export function saveFolder(folder: FolderState): ThunkResult<void> {
export function deleteFolder(uid: string): ThunkResult<void> {
return async dispatch => {
await backendSrv.deleteFolder(uid, true);
await backendSrv.delete(`/api/folders/${uid}`);
dispatch(updateLocation({ path: `dashboards` }));
};
}
......
......@@ -3,7 +3,7 @@ import { dateTimeFormat } from '@grafana/data';
import { Legend, Form } from '@grafana/ui';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { ImportDashboardForm } from './ImportDashboardForm';
import { clearLoadedDashboard, saveDashboard } from '../state/actions';
import { clearLoadedDashboard, importDashboard } from '../state/actions';
import { DashboardInputs, DashboardSource, ImportDashboardDTO } from '../state/reducers';
import { StoreState } from 'app/types';
......@@ -19,7 +19,7 @@ interface ConnectedProps {
interface DispatchProps {
clearLoadedDashboard: typeof clearLoadedDashboard;
saveDashboard: typeof saveDashboard;
importDashboard: typeof importDashboard;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
......@@ -34,7 +34,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
};
onSubmit = (form: ImportDashboardDTO) => {
this.props.saveDashboard(form);
this.props.importDashboard(form);
};
onCancel = () => {
......@@ -116,7 +116,7 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
clearLoadedDashboard,
saveDashboard,
importDashboard,
};
export const ImportDashboardOverview = connect(mapStateToProps, mapDispatchToProps)(ImportDashboardOverviewUnConnected);
......
import { AppEvents, DataSourceInstanceSettings, DataSourceSelectItem, locationUtil } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import config from 'app/core/config';
import {
clearDashboard,
......@@ -10,7 +10,7 @@ import {
ImportDashboardDTO,
} from './reducers';
import { updateLocation } from 'app/core/actions';
import { ThunkResult } from 'app/types';
import { ThunkResult, FolderInfo, DashboardDTO, DashboardDataDTO } from 'app/types';
import { appEvents } from '../../../core/core';
export function fetchGcomDashboard(id: string): ThunkResult<void> {
......@@ -66,7 +66,7 @@ export function clearLoadedDashboard(): ThunkResult<void> {
};
}
export function saveDashboard(importDashboardForm: ImportDashboardDTO): ThunkResult<void> {
export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkResult<void> {
return async (dispatch, getState) => {
const dashboard = getState().importDashboard.dashboard;
const inputs = getState().importDashboard.inputs;
......@@ -118,3 +118,123 @@ const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, i
return { name: val.name, value: val.name, meta: val.meta };
});
};
export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) {
const tasks = [];
for (const uid of dashboardUids) {
tasks.push(createTask(moveDashboard, true, uid, toFolder));
}
return executeInOrder(tasks).then((result: any) => {
return {
totalCount: result.length,
successCount: result.filter((res: any) => res.succeeded).length,
alreadyInFolderCount: result.filter((res: any) => res.alreadyInFolder).length,
};
});
}
async function moveDashboard(uid: string, toFolder: FolderInfo) {
const fullDash: DashboardDTO = await getBackendSrv().getDashboardByUid(uid);
if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {
return { alreadyInFolder: true };
}
const options = {
dashboard: fullDash.dashboard,
folderId: toFolder.id,
overwrite: false,
};
try {
await saveDashboard(options);
return { succeeded: true };
} catch (err) {
if (err.data?.status !== 'plugin-dashboard') {
return { succeeded: false };
}
err.isHandled = true;
options.overwrite = true;
try {
await saveDashboard(options);
return { succeeded: true };
} catch (e) {
return { succeeded: false };
}
}
}
function createTask(fn: (...args: any[]) => Promise<any>, ignoreRejections: boolean, ...args: any[]) {
return async (result: any) => {
try {
const res = await fn(...args);
return Array.prototype.concat(result, [res]);
} catch (err) {
if (ignoreRejections) {
return result;
}
throw err;
}
};
}
export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
const tasks = [];
for (const folderUid of folderUids) {
tasks.push(createTask(deleteFolder, true, folderUid, true));
}
for (const dashboardUid of dashboardUids) {
tasks.push(createTask(deleteDashboard, true, dashboardUid, true));
}
return executeInOrder(tasks);
}
export interface SaveDashboardOptions {
dashboard: DashboardDataDTO;
message?: string;
folderId?: number;
overwrite?: boolean;
}
export function saveDashboard(options: SaveDashboardOptions) {
return getBackendSrv().post('/api/dashboards/db/', {
dashboard: options.dashboard,
message: options.message ?? '',
overwrite: options.overwrite ?? false,
folderId: options.folderId,
});
}
function deleteFolder(uid: string, showSuccessAlert: boolean) {
return getBackendSrv().request({
method: 'DELETE',
url: `/api/folders/${uid}`,
showSuccessAlert: showSuccessAlert === true,
});
}
export function createFolder(payload: any) {
return getBackendSrv().post('/api/folders', payload);
}
export function deleteDashboard(uid: string, showSuccessAlert: boolean) {
return getBackendSrv().request({
method: 'DELETE',
url: `/api/dashboards/uid/${uid}`,
showSuccessAlert: showSuccessAlert === true,
});
}
function executeInOrder(tasks: any[]) {
return tasks.reduce((acc, task) => {
return Promise.resolve(acc).then(task);
}, []);
}
......@@ -3,9 +3,9 @@ import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui';
import { getLocationSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSection, OnDeleteItems } from '../types';
import { getCheckedUids } from '../utils';
import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions';
interface Props {
onDeleteItems: OnDeleteItems;
......@@ -38,7 +38,7 @@ export const ConfirmDeleteModal: FC<Props> = ({ results, onDeleteItems, isOpen,
}
const deleteItems = () => {
backendSrv.deleteFoldersAndDashboards(folders, dashboards).then(() => {
deleteFoldersAndDashboards(folders, dashboards).then(() => {
onDismiss();
// Redirect to /dashboard in case folder was deleted from f/:folder.uid
getLocationSrv().update({ path: '/dashboards' });
......
......@@ -5,9 +5,9 @@ import { AppEvents, GrafanaTheme } from '@grafana/data';
import { FolderInfo } from 'app/types';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import appEvents from 'app/core/app_events';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSection, OnMoveItems } from '../types';
import { getCheckedDashboards } from '../utils';
import { moveDashboards } from 'app/features/manage-dashboards/state/actions';
interface Props {
onMoveItems: OnMoveItems;
......@@ -26,7 +26,7 @@ export const MoveToFolderModal: FC<Props> = ({ results, onMoveItems, isOpen, onD
if (folder && selectedDashboards.length) {
const folderTitle = folder.title ?? 'General';
backendSrv.moveDashboards(selectedDashboards.map(d => d.uid) as string[], folder).then((result: any) => {
moveDashboards(selectedDashboards.map(d => d.uid) as string[], folder).then((result: any) => {
if (result.successCount > 0) {
const ending = result.successCount === 1 ? '' : 's';
const header = `Dashboard${ending} Moved`;
......
......@@ -9,12 +9,11 @@ import {
DataQuery,
FieldType,
} from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv, BackendSrvRequest } from '@grafana/runtime';
import { Observable, from, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DatasourceRequestOptions } from 'app/core/services/backend_srv';
import { serializeParams } from 'app/core/utils/fetch';
export type JaegerQuery = {
......@@ -87,7 +86,7 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
return query.query;
}
private _request(apiUrl: string, data?: any, options?: DatasourceRequestOptions): Observable<Record<string, any>> {
private _request(apiUrl: string, data?: any, options?: Partial<BackendSrvRequest>): Observable<Record<string, any>> {
// Hack for proxying metadata requests
const baseUrl = `/api/datasources/proxy/${this.instanceSettings.id}`;
const params = data ? serializeParams(data) : '';
......
......@@ -5,9 +5,8 @@ import { map, filter, catchError, switchMap } from 'rxjs/operators';
// Services & Utils
import { DataFrame, dateMath, FieldCache, QueryResultMeta } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv, BackendSrvRequest } from '@grafana/runtime';
import { addLabelToQuery } from 'app/plugins/datasource/prometheus/add_label_to_query';
import { DatasourceRequestOptions } from 'app/core/services/backend_srv';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { safeStringifyValue, convertToWebSocketUrl } from 'app/core/utils/explore';
import { lokiResultsToTableModel, processRangeQueryResponse, lokiStreamResultToDataFrame } from './result_transformer';
......@@ -72,7 +71,7 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
this.maxLines = parseInt(settingsData.maxLines ?? '0', 10) || DEFAULT_MAX_LINES;
}
_request(apiUrl: string, data?: any, options?: DatasourceRequestOptions): Observable<Record<string, any>> {
_request(apiUrl: string, data?: any, options?: Partial<BackendSrvRequest>): Observable<Record<string, any>> {
const baseUrl = this.instanceSettings.url;
const params = data ? serializeParams(data) : '';
const url = `${baseUrl}${apiUrl}${params.length ? `?${params}` : ''}`;
......
// Libraries
import cloneDeep from 'lodash/cloneDeep';
import defaults from 'lodash/defaults';
import $ from 'jquery';
// Services & Utils
import kbn from 'app/core/utils/kbn';
import {
......@@ -130,7 +129,6 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
}
} else {
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.transformRequest = (data: any) => $.param(data);
options.data = data;
}
......
......@@ -17,6 +17,7 @@ import { Scenario, TestDataQuery } from './types';
import { getBackendSrv, toDataQueryError } from '@grafana/runtime';
import { queryMetricTree } from './metricTree';
import { from, merge, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { runStream } from './runStreams';
import templateSrv from 'app/features/templating/template_srv';
import { getSearchFilterScopedVar } from 'app/features/variables/utils';
......@@ -55,8 +56,8 @@ export class TestDataDataSource extends DataSourceApi<TestDataQuery> {
}
if (queries.length) {
const req: Promise<DataQueryResponse> = getBackendSrv()
.datasourceRequest({
const stream = getBackendSrv()
.fetch({
method: 'POST',
url: '/api/tsdb/query',
data: {
......@@ -64,12 +65,10 @@ export class TestDataDataSource extends DataSourceApi<TestDataQuery> {
to: options.range.to.valueOf().toString(),
queries: queries,
},
// This sets up a cancel token
requestId: options.requestId,
})
.then((res: any) => this.processQueryResult(queries, res));
.pipe(map(res => this.processQueryResult(queries, res)));
streams.push(from(req));
streams.push(stream);
}
return merge(...streams);
......
......@@ -8,9 +8,8 @@ import {
FieldType,
} from '@grafana/data';
import { from, Observable, of } from 'rxjs';
import { DatasourceRequestOptions } from '../../../core/services/backend_srv';
import { serializeParams } from '../../../core/utils/fetch';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv, BackendSrvRequest } from '@grafana/runtime';
import { map } from 'rxjs/operators';
import { apiPrefix } from './constants';
import { ZipkinSpan } from './types';
......@@ -48,7 +47,7 @@ export class ZipkinDatasource extends DataSourceApi<ZipkinQuery> {
return query.query;
}
private request<T = any>(apiUrl: string, data?: any, options?: DatasourceRequestOptions): Observable<{ data: T }> {
private request<T = any>(apiUrl: string, data?: any, options?: Partial<BackendSrvRequest>): Observable<{ data: T }> {
// Hack for proxying metadata requests
const baseUrl = `/api/datasources/proxy/${this.instanceSettings.id}`;
const params = data ? serializeParams(data) : '';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment