mirror of https://github.com/grafana/grafana.git
321 lines
9.7 KiB
TypeScript
321 lines
9.7 KiB
TypeScript
import {
|
|
Action,
|
|
ActionModel,
|
|
ActionType,
|
|
ActionVariableInput,
|
|
AppEvents,
|
|
DataContextScopedVar,
|
|
DataFrame,
|
|
DataLink,
|
|
Field,
|
|
FieldType,
|
|
getFieldDataContextClone,
|
|
InterpolateFunction,
|
|
InfinityOptions,
|
|
ScopedVars,
|
|
textUtil,
|
|
ValueLinkConfig,
|
|
} from '@grafana/data';
|
|
import { BackendSrvRequest, config as grafanaConfig, getBackendSrv } from '@grafana/runtime';
|
|
import { appEvents } from 'app/core/core';
|
|
|
|
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
|
|
import { createAbsoluteUrl, RelativeUrl } from '../alerting/unified/utils/url';
|
|
import { getTimeSrv } from '../dashboard/services/TimeSrv';
|
|
import { getNextRequestId } from '../query/state/PanelQueryRunner';
|
|
|
|
import { reportActionTrigger } from './analytics';
|
|
|
|
/** @internal */
|
|
export const isInfinityActionWithAuth = (action: Action): boolean => {
|
|
return (grafanaConfig.featureToggles.vizActionsAuth ?? false) && action.type === ActionType.Infinity;
|
|
};
|
|
|
|
/** @internal */
|
|
export const genReplaceActionVars = (
|
|
boundReplaceVariables: InterpolateFunction,
|
|
action: Action,
|
|
actionVars?: ActionVariableInput
|
|
): InterpolateFunction => {
|
|
return (value, scopedVars, format) => {
|
|
if (action.variables && actionVars) {
|
|
value = value.replace(/\$\w+/g, (matched) => {
|
|
const name = matched.slice(1);
|
|
|
|
if (action.variables!.some((action) => action.key === name) && actionVars[name] != null) {
|
|
return actionVars[name];
|
|
}
|
|
|
|
return matched;
|
|
});
|
|
}
|
|
|
|
return boundReplaceVariables(value, scopedVars, format);
|
|
};
|
|
};
|
|
|
|
/** @internal */
|
|
export const getActions = (
|
|
frame: DataFrame,
|
|
field: Field,
|
|
fieldScopedVars: ScopedVars,
|
|
replaceVariables: InterpolateFunction,
|
|
actions: Action[],
|
|
config: ValueLinkConfig,
|
|
visualizationType?: string
|
|
): Array<ActionModel<Field>> => {
|
|
if (!actions || actions.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const actionModels = actions
|
|
.filter((action) => {
|
|
return action.type === ActionType.Fetch || isInfinityActionWithAuth(action);
|
|
})
|
|
.map((action: Action) => {
|
|
const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars);
|
|
const actionScopedVars = {
|
|
...fieldScopedVars,
|
|
__dataContext: dataContext,
|
|
};
|
|
|
|
const boundReplaceVariables: InterpolateFunction = (value, scopedVars, format) => {
|
|
return replaceVariables(value, { ...actionScopedVars, ...scopedVars }, format);
|
|
};
|
|
|
|
// We are not displaying reduction result
|
|
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
|
|
dataContext.value.rowIndex = config.valueRowIndex;
|
|
} else {
|
|
dataContext.value.calculatedValue = config.calculatedValue;
|
|
}
|
|
|
|
const actionModel: ActionModel<Field> = {
|
|
title: replaceVariables(action.title, actionScopedVars),
|
|
type: action.type,
|
|
confirmation: (actionVars?: ActionVariableInput) =>
|
|
genReplaceActionVars(
|
|
boundReplaceVariables,
|
|
action,
|
|
actionVars
|
|
)(action.confirmation || `Are you sure you want to ${action.title}?`),
|
|
onClick: (evt: MouseEvent, origin: Field, actionVars?: ActionVariableInput) => {
|
|
if (visualizationType) {
|
|
reportActionTrigger(action.type, action.oneClick ?? false, visualizationType);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
let request = {} as BackendSrvRequest;
|
|
if (isInfinityActionWithAuth(action)) {
|
|
request = buildActionProxyRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
|
|
} else if (action.type === ActionType.Fetch) {
|
|
request = buildActionRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
|
|
}
|
|
|
|
try {
|
|
getBackendSrv()
|
|
.fetch(request)
|
|
.subscribe({
|
|
error: (error) => {
|
|
appEvents.emit(AppEvents.alertError, [
|
|
'An error has occurred. Check console output for more details.',
|
|
]);
|
|
console.error(error);
|
|
},
|
|
complete: () => {
|
|
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
|
|
},
|
|
});
|
|
} catch (error) {
|
|
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
|
console.error(error);
|
|
return;
|
|
}
|
|
},
|
|
oneClick: action.oneClick ?? false,
|
|
style: {
|
|
backgroundColor: action.style?.backgroundColor ?? grafanaConfig.theme2.colors.secondary.main,
|
|
},
|
|
variables: action.variables,
|
|
};
|
|
|
|
return actionModel;
|
|
});
|
|
|
|
return actionModels.filter((action): action is ActionModel => !!action);
|
|
};
|
|
|
|
/** @internal */
|
|
const processActionConfig = (action: Action, replaceVariables: InterpolateFunction) => {
|
|
const config = action[action.type];
|
|
if (!config) {
|
|
throw new Error('Action does not have the correct configuration');
|
|
}
|
|
|
|
const url = new URL(getUrl(replaceVariables(config.url)));
|
|
const data = config.method === HttpRequestMethod.GET ? undefined : config.body ? replaceVariables(config.body) : '{}';
|
|
|
|
const processedHeaders: Array<[string, string]> = [];
|
|
const processedQueryParams: Array<[string, string]> = [];
|
|
let contentType = 'application/json';
|
|
|
|
if (config.headers) {
|
|
config.headers.forEach(([name, value]) => {
|
|
const processedName = replaceVariables(name);
|
|
const processedValue = replaceVariables(value);
|
|
processedHeaders.push([processedName, processedValue]);
|
|
|
|
if (processedName.toLowerCase() === 'content-type') {
|
|
contentType = processedValue;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (config.queryParams) {
|
|
config.queryParams.forEach(([name, value]) => {
|
|
processedQueryParams.push([replaceVariables(name), replaceVariables(value)]);
|
|
});
|
|
}
|
|
|
|
return {
|
|
config,
|
|
url,
|
|
data,
|
|
processedHeaders,
|
|
processedQueryParams,
|
|
contentType,
|
|
};
|
|
};
|
|
|
|
/** @internal */
|
|
export const buildActionRequest = (action: Action, replaceVariables: InterpolateFunction) => {
|
|
const { config, url, data, processedHeaders, processedQueryParams } = processActionConfig(action, replaceVariables);
|
|
|
|
const requestHeaders: Record<string, string> = {};
|
|
|
|
processedHeaders.forEach(([name, value]) => {
|
|
requestHeaders[name] = value;
|
|
});
|
|
|
|
processedQueryParams.forEach(([name, value]) => {
|
|
url.searchParams.append(name, value);
|
|
});
|
|
|
|
requestHeaders['X-Grafana-Action'] = '1';
|
|
|
|
const request: BackendSrvRequest = {
|
|
url: url.toString(),
|
|
method: config.method,
|
|
data,
|
|
headers: requestHeaders,
|
|
};
|
|
|
|
return request;
|
|
};
|
|
|
|
/** @internal */
|
|
export const getActionsDefaultField = (dataLinks: DataLink[] = [], actions: Action[] = []): Field => {
|
|
return {
|
|
name: 'Default field',
|
|
type: FieldType.string,
|
|
config: { links: dataLinks, actions: actions },
|
|
values: [],
|
|
};
|
|
};
|
|
|
|
/** @internal */
|
|
const getUrl = (endpoint: string) => {
|
|
const isRelativeUrl = endpoint.startsWith('/');
|
|
if (isRelativeUrl) {
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const sanitizedRelativeURL = textUtil.sanitizeUrl(endpoint) as RelativeUrl;
|
|
endpoint = createAbsoluteUrl(sanitizedRelativeURL, []);
|
|
}
|
|
|
|
return endpoint;
|
|
};
|
|
|
|
/** @internal */
|
|
interface KeyValuePair {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
export const INFINITY_DATASOURCE_TYPE = 'yesoreyeram-infinity-datasource';
|
|
|
|
/** @internal */
|
|
class InfinityRequestBuilder {
|
|
buildRequest(
|
|
proxyConfig: InfinityOptions,
|
|
url: URL,
|
|
data: string | undefined,
|
|
headers: Array<[string, string]>,
|
|
queryParams: Array<[string, string]>,
|
|
contentType: string
|
|
): BackendSrvRequest {
|
|
const requestId = getNextRequestId();
|
|
const infinityUrl = `api/ds/query?ds_type=${INFINITY_DATASOURCE_TYPE}&requestId=${requestId}`;
|
|
const timeRange = getTimeSrv().timeRange();
|
|
|
|
const requestHeaders: KeyValuePair[] = [];
|
|
headers.forEach(([name, value]) => {
|
|
requestHeaders.push({ key: name, value: value });
|
|
});
|
|
|
|
// Infinity needs [string, string] to {key: string, value: string}
|
|
const requestQueryParams: KeyValuePair[] = [];
|
|
queryParams.forEach(([name, value]) => {
|
|
requestQueryParams.push({ key: name, value: value });
|
|
});
|
|
|
|
const infinityUrlOptions = {
|
|
method: proxyConfig.method,
|
|
data,
|
|
headers: requestHeaders,
|
|
params: requestQueryParams,
|
|
body_type: 'raw',
|
|
body_content_type: contentType,
|
|
};
|
|
|
|
return {
|
|
url: infinityUrl,
|
|
method: HttpRequestMethod.POST,
|
|
data: {
|
|
queries: [
|
|
{
|
|
refId: 'A',
|
|
datasource: {
|
|
type: INFINITY_DATASOURCE_TYPE,
|
|
uid: proxyConfig.datasourceUid,
|
|
},
|
|
type: 'json',
|
|
source: 'url',
|
|
format: 'as-is',
|
|
url,
|
|
url_options: infinityUrlOptions,
|
|
},
|
|
],
|
|
from: timeRange.from.valueOf().toString(),
|
|
to: timeRange.to.valueOf().toString(),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/** @internal */
|
|
export const buildActionProxyRequest = (action: Action, replaceVariables: InterpolateFunction) => {
|
|
const { config, url, data, processedHeaders, processedQueryParams, contentType } = processActionConfig(
|
|
action,
|
|
replaceVariables
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const infinityConfig = config as InfinityOptions;
|
|
if (!infinityConfig.datasourceUid) {
|
|
throw new Error('Datasource not configured for Infinity action');
|
|
}
|
|
|
|
const requestBuilder = new InfinityRequestBuilder();
|
|
return requestBuilder.buildRequest(infinityConfig, url, data, processedHeaders, processedQueryParams, contentType);
|
|
};
|