mirror of
https://github.com/grafana/grafana.git
synced 2025-02-15 10:03:33 -06:00
616 lines
19 KiB
TypeScript
616 lines
19 KiB
TypeScript
import omitBy from 'lodash/omitBy';
|
|
import { from, merge, MonoTypeOperatorFunction, Observable, of, 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 { AppEvents } from '@grafana/data';
|
|
|
|
import appEvents from 'app/core/app_events';
|
|
import config from 'app/core/config';
|
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
|
import { DashboardSearchHit } from 'app/types/search';
|
|
import { CoreEvents, DashboardDTO, FolderInfo } from 'app/types';
|
|
import { ContextSrv, contextSrv } from './context_srv';
|
|
import { coreModule } from 'app/core/core_module';
|
|
import { Emitter } from '../utils/emitter';
|
|
import { DataSourceResponse } from '../../types/events';
|
|
|
|
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,
|
|
}
|
|
|
|
function serializeParams(data: Record<string, any>): string {
|
|
return Object.keys(data)
|
|
.map(key => {
|
|
const value = data[key];
|
|
if (Array.isArray(value)) {
|
|
return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&');
|
|
}
|
|
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
})
|
|
.join('&');
|
|
}
|
|
|
|
export interface BackendSrvDependencies {
|
|
fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>;
|
|
appEvents: Emitter;
|
|
contextSrv: ContextSrv;
|
|
logout: () => void;
|
|
}
|
|
|
|
export class BackendSrv implements BackendService {
|
|
private inFlightRequests: Subject<string> = new Subject<string>();
|
|
private HTTP_REQUEST_CANCELED = -1;
|
|
private noBackendCache: boolean;
|
|
private dependencies: BackendSrvDependencies = {
|
|
fromFetch: fromFetch,
|
|
appEvents: appEvents,
|
|
contextSrv: contextSrv,
|
|
logout: () => {
|
|
window.location.href = config.appSubUrl + '/logout';
|
|
},
|
|
};
|
|
|
|
constructor(deps?: BackendSrvDependencies) {
|
|
if (deps) {
|
|
this.dependencies = {
|
|
...this.dependencies,
|
|
...deps,
|
|
};
|
|
}
|
|
}
|
|
|
|
async get(url: string, params?: any, requestId?: string) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
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
|
|
if (options.requestId) {
|
|
this.inFlightRequests.next(options.requestId);
|
|
}
|
|
|
|
options = this.parseRequestOptions(options, this.dependencies.contextSrv.user?.orgId);
|
|
|
|
const fromFetchStream = this.getFromFetchStream(options);
|
|
const failureStream = fromFetchStream.pipe(this.toFailureStream(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]);
|
|
}
|
|
})
|
|
);
|
|
|
|
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();
|
|
}
|
|
|
|
resolveCancelerIfExists(requestId: string) {
|
|
this.inFlightRequests.next(requestId);
|
|
}
|
|
|
|
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(`/api/folders/${uid}`);
|
|
}
|
|
|
|
saveDashboard(
|
|
dash: DashboardModel,
|
|
{ message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {}
|
|
) {
|
|
return this.post('/api/dashboards/db/', {
|
|
dashboard: dash,
|
|
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);
|
|
const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
|
|
|
|
if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {
|
|
return { alreadyInFolder: true };
|
|
}
|
|
|
|
const clone = model.getSaveModelClone();
|
|
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;
|
|
}
|
|
};
|
|
}
|
|
|
|
private executeInOrder(tasks: any[]) {
|
|
return tasks.reduce((acc, task) => {
|
|
return Promise.resolve(acc).then(task);
|
|
}, []);
|
|
}
|
|
|
|
private parseRequestOptions = (options: BackendSrvRequest, orgId?: number): 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);
|
|
}
|
|
}
|
|
|
|
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.headers?.Authorization) {
|
|
options.headers['X-DS-Authorization'] = options.headers.Authorization;
|
|
delete options.headers.Authorization;
|
|
}
|
|
|
|
if (noBackendCache) {
|
|
options.headers['X-Grafana-NoCache'] = 'true';
|
|
}
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
private getFromFetchStream = (options: BackendSrvRequest) => {
|
|
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;
|
|
try {
|
|
data = JSON.parse(textData); // majority of the requests this will be something that can be parsed
|
|
} catch {
|
|
data = textData;
|
|
}
|
|
const fetchResponse: FetchResponse = {
|
|
status,
|
|
statusText,
|
|
ok,
|
|
data,
|
|
headers,
|
|
url,
|
|
type,
|
|
redirected,
|
|
request: { url, ...init },
|
|
};
|
|
return fetchResponse;
|
|
}),
|
|
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 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 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;
|
|
}
|
|
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,
|
|
})),
|
|
catchError(err => {
|
|
if (!err.cancelled) {
|
|
return throwError(err);
|
|
}
|
|
|
|
if (resultType === CancellationType.dataSourceRequest) {
|
|
return of({
|
|
data: [],
|
|
status: this.HTTP_REQUEST_CANCELED,
|
|
statusText: 'Request was aborted',
|
|
request: { url: parseUrlFromOptions(options), ...parseInitFromOptions(options) },
|
|
});
|
|
}
|
|
|
|
return of([]);
|
|
})
|
|
);
|
|
}
|
|
|
|
coreModule.factory('backendSrv', () => backendSrv);
|
|
// Used for testing and things that really need BackendSrv
|
|
export const backendSrv = new BackendSrv();
|
|
export const getBackendSrv = (): BackendSrv => backendSrv;
|
|
|
|
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
|
|
const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0));
|
|
const serializedParams = serializeParams(cleanParams);
|
|
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
|
|
};
|
|
|
|
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
|
|
const method = options.method;
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, text/plain, */*',
|
|
...options.headers,
|
|
};
|
|
const body = parseBody({ ...options, headers });
|
|
return {
|
|
method,
|
|
headers,
|
|
body,
|
|
};
|
|
};
|
|
|
|
const parseBody = (options: BackendSrvRequest) => {
|
|
if (!options.data || typeof options.data === 'string') {
|
|
return options.data;
|
|
}
|
|
|
|
if (options.headers['Content-Type'] === 'application/json') {
|
|
return JSON.stringify(options.data);
|
|
}
|
|
|
|
return new URLSearchParams(options.data);
|
|
};
|