mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
FieldOverride: Support data links via field overrides (#23590)
* Move xss and sanitize packages to grafana-data
* Move text, url and location utils to grafana-data
* Move grafana config types to grafana-data
* Move field display value proxy to grafana-data
* Fix
* Move data links built in vars to grafana-data
* Attach links supplier to when applying field overrides
* Prep tests
* Use links suppliers attached via field overrides
* locationUtil dependencies type
* Move sanitize-url declaration to grafana-data
* Revert "Move sanitize-url declaration to grafana-data"
This reverts commit 11db9f5e55.
* Fix typo
* fix ts vol1
* Remove import from runtime in data.... Make TS happy at the same time ;)
* Lovely TS, please shut up
* Lovely TS, please shut up vol2
* fix tests
* Fixes
* minor refactor
* Attach get links to FieldDisplayValue for seamless usage
* Update packages/grafana-data/src/field/fieldOverrides.ts
* Make storybook build
This commit is contained in:
@@ -25,7 +25,9 @@
|
||||
"dependencies": {
|
||||
"apache-arrow": "0.16.0",
|
||||
"lodash": "4.17.15",
|
||||
"rxjs": "6.5.5"
|
||||
"rxjs": "6.5.5",
|
||||
"xss": "1.0.6",
|
||||
"@braintree/sanitize-url": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grafana/eslint-config": "^1.0.0-rc1",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FieldConfigSource,
|
||||
FieldType,
|
||||
InterpolateFunction,
|
||||
LinkModel,
|
||||
} from '../types';
|
||||
import { DataFrameView } from '../dataframe/DataFrameView';
|
||||
import { GraphSeriesValue } from '../types/graph';
|
||||
@@ -77,6 +78,7 @@ export interface FieldDisplay {
|
||||
view?: DataFrameView;
|
||||
colIndex?: number; // The field column index
|
||||
rowIndex?: number; // only filled in when the value is from a row (ie, not a reduction)
|
||||
getLinks?: () => LinkModel[];
|
||||
}
|
||||
|
||||
export interface GetFieldDisplayValuesOptions {
|
||||
@@ -113,7 +115,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
|
||||
for (let i = 0; i < series.fields.length && !hitLimit; i++) {
|
||||
const field = series.fields[i];
|
||||
|
||||
const fieldLinksSupplier = field.getLinks;
|
||||
// Show all number fields
|
||||
if (field.type !== FieldType.number) {
|
||||
continue;
|
||||
@@ -157,6 +159,12 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
view,
|
||||
colIndex: i,
|
||||
rowIndex: j,
|
||||
getLinks: fieldLinksSupplier
|
||||
? () =>
|
||||
fieldLinksSupplier({
|
||||
valueRowIndex: j,
|
||||
})
|
||||
: () => [],
|
||||
});
|
||||
|
||||
if (values.length >= limit) {
|
||||
@@ -193,6 +201,12 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
sparkline,
|
||||
view,
|
||||
colIndex: i,
|
||||
getLinks: fieldLinksSupplier
|
||||
? () =>
|
||||
fieldLinksSupplier({
|
||||
calculatedValue: displayValue,
|
||||
})
|
||||
: () => [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
ScopedVars,
|
||||
ApplyFieldOverrideOptions,
|
||||
FieldConfigPropertyItem,
|
||||
LinkModel,
|
||||
InterpolateFunction,
|
||||
ValueLinkConfig,
|
||||
GrafanaTheme,
|
||||
} from '../types';
|
||||
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
|
||||
import { FieldMatcher } from '../types/transformations';
|
||||
@@ -19,9 +23,12 @@ import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
import get from 'lodash/get';
|
||||
import { getDisplayProcessor } from './displayProcessor';
|
||||
import { guessFieldTypeForField } from '../dataframe';
|
||||
import { getTimeField, guessFieldTypeForField } from '../dataframe';
|
||||
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
|
||||
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
import { DataLinkBuiltInVars, locationUtil } from '../utils';
|
||||
import { formattedValueToString } from '../valueFormats';
|
||||
import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
|
||||
interface OverrideProps {
|
||||
match: FieldMatcher;
|
||||
@@ -102,10 +109,10 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
if (!fieldName) {
|
||||
fieldName = `Field[${fieldIndex}]`;
|
||||
}
|
||||
const fieldScopedVars = { ...scopedVars };
|
||||
fieldScopedVars['__field'] = { text: 'Field', value: { name: fieldName } };
|
||||
|
||||
scopedVars['__field'] = { text: 'Field', value: { name: fieldName } };
|
||||
|
||||
const config: FieldConfig = { ...field.config, scopedVars } || {};
|
||||
const config: FieldConfig = { ...field.config, scopedVars: fieldScopedVars } || {};
|
||||
const context = {
|
||||
field,
|
||||
data: options.data!,
|
||||
@@ -182,6 +189,12 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
theme: options.theme,
|
||||
timeZone: options.timeZone,
|
||||
});
|
||||
|
||||
// Attach data links supplier
|
||||
f.getLinks = getLinksSupplier(frame, f, fieldScopedVars, context.replaceVariables, {
|
||||
theme: options.theme,
|
||||
});
|
||||
|
||||
return f;
|
||||
});
|
||||
|
||||
@@ -314,3 +327,84 @@ export function validateFieldConfig(config: FieldConfig) {
|
||||
config.min = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
const getLinksSupplier = (
|
||||
frame: DataFrame,
|
||||
field: Field,
|
||||
fieldScopedVars: ScopedVars,
|
||||
replaceVariables: InterpolateFunction,
|
||||
options: {
|
||||
theme: GrafanaTheme;
|
||||
}
|
||||
) => (config: ValueLinkConfig): Array<LinkModel<Field>> => {
|
||||
if (!field.config.links || field.config.links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const timeRangeUrl = locationUtil.getTimeRangeUrlParams();
|
||||
const { timeField } = getTimeField(frame);
|
||||
|
||||
return field.config.links.map(link => {
|
||||
let href = link.url;
|
||||
let dataFrameVars = {};
|
||||
let valueVars = {};
|
||||
|
||||
const info: LinkModel<Field> = {
|
||||
href: locationUtil.assureBaseUrl(href.replace(/\n/g, '')),
|
||||
title: replaceVariables(link.title || ''),
|
||||
target: link.targetBlank ? '_blank' : '_self',
|
||||
origin: field,
|
||||
};
|
||||
|
||||
const variablesQuery = locationUtil.getVariablesUrlParams();
|
||||
|
||||
// We are not displaying reduction result
|
||||
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
|
||||
const fieldsProxy = getFieldDisplayValuesProxy(frame, config.valueRowIndex, options);
|
||||
valueVars = {
|
||||
raw: field.values.get(config.valueRowIndex),
|
||||
numeric: fieldsProxy[field.name].numeric,
|
||||
text: fieldsProxy[field.name].text,
|
||||
time: timeField ? timeField.values.get(config.valueRowIndex) : undefined,
|
||||
};
|
||||
dataFrameVars = {
|
||||
__data: {
|
||||
value: {
|
||||
name: frame.name,
|
||||
refId: frame.refId,
|
||||
fields: fieldsProxy,
|
||||
},
|
||||
text: 'Data',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (config.calculatedValue) {
|
||||
valueVars = {
|
||||
raw: config.calculatedValue.numeric,
|
||||
numeric: config.calculatedValue.numeric,
|
||||
text: formattedValueToString(config.calculatedValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
info.href = replaceVariables(info.href, {
|
||||
...fieldScopedVars,
|
||||
__value: {
|
||||
text: 'Value',
|
||||
value: valueVars,
|
||||
},
|
||||
...dataFrameVars,
|
||||
[DataLinkBuiltInVars.keepTime]: {
|
||||
text: timeRangeUrl,
|
||||
value: timeRangeUrl,
|
||||
},
|
||||
[DataLinkBuiltInVars.includeVars]: {
|
||||
text: variablesQuery,
|
||||
value: variablesQuery,
|
||||
},
|
||||
});
|
||||
|
||||
info.href = locationUtil.processUrl(info.href);
|
||||
|
||||
return info;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
import { applyFieldOverrides } from './fieldOverrides';
|
||||
import { toDataFrame } from '../dataframe';
|
||||
import { GrafanaTheme } from '../types';
|
||||
|
||||
describe('getFieldDisplayValuesProxy', () => {
|
||||
const data = applyFieldOverrides({
|
||||
data: [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', values: [1, 2, 3] },
|
||||
{
|
||||
name: 'power',
|
||||
values: [100, 200, 300],
|
||||
config: {
|
||||
title: 'The Power',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Last',
|
||||
values: ['a', 'b', 'c'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (val: string) => val,
|
||||
timeZone: 'utc',
|
||||
theme: {} as GrafanaTheme,
|
||||
autoMinMax: true,
|
||||
})[0];
|
||||
|
||||
it('should define all display functions', () => {
|
||||
// Field display should be set
|
||||
for (const field of data.fields) {
|
||||
expect(field.display).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should format the time values in UTC', () => {
|
||||
// Test Proxies in general
|
||||
const p = getFieldDisplayValuesProxy(data, 0, {
|
||||
theme: {} as GrafanaTheme,
|
||||
});
|
||||
const time = p.Time;
|
||||
expect(time.numeric).toEqual(1);
|
||||
expect(time.text).toEqual('1970-01-01 00:00:00');
|
||||
|
||||
// Should get to the same values by name or index
|
||||
const time2 = p[0];
|
||||
expect(time2.toString()).toEqual(time.toString());
|
||||
});
|
||||
|
||||
it('Lookup by name, index, or title', () => {
|
||||
const p = getFieldDisplayValuesProxy(data, 2, {
|
||||
theme: {} as GrafanaTheme,
|
||||
});
|
||||
expect(p.power.numeric).toEqual(300);
|
||||
expect(p['power'].numeric).toEqual(300);
|
||||
expect(p['The Power'].numeric).toEqual(300);
|
||||
expect(p[1].numeric).toEqual(300);
|
||||
});
|
||||
|
||||
it('should return undefined when missing', () => {
|
||||
const p = getFieldDisplayValuesProxy(data, 0, {
|
||||
theme: {} as GrafanaTheme,
|
||||
});
|
||||
expect(p.xyz).toBeUndefined();
|
||||
expect(p[100]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import toNumber from 'lodash/toNumber';
|
||||
import { DataFrame, DisplayValue, GrafanaTheme } from '../types';
|
||||
import { getDisplayProcessor } from './displayProcessor';
|
||||
import { formattedValueToString } from '../valueFormats';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param frame
|
||||
* @param rowIndex
|
||||
* @param options
|
||||
* @internal
|
||||
*/
|
||||
export function getFieldDisplayValuesProxy(
|
||||
frame: DataFrame,
|
||||
rowIndex: number,
|
||||
options: {
|
||||
theme: GrafanaTheme;
|
||||
}
|
||||
): Record<string, DisplayValue> {
|
||||
return new Proxy({} as Record<string, DisplayValue>, {
|
||||
get: (obj: any, key: string) => {
|
||||
// 1. Match the name
|
||||
let field = frame.fields.find(f => key === f.name);
|
||||
if (!field) {
|
||||
// 2. Match the array index
|
||||
const k = toNumber(key);
|
||||
field = frame.fields[k];
|
||||
}
|
||||
if (!field) {
|
||||
// 3. Match the title
|
||||
field = frame.fields.find(f => key === f.config.title);
|
||||
}
|
||||
if (!field) {
|
||||
return undefined;
|
||||
}
|
||||
if (!field.display) {
|
||||
// Lazy load the display processor
|
||||
field.display = getDisplayProcessor({
|
||||
field,
|
||||
theme: options.theme,
|
||||
});
|
||||
}
|
||||
const raw = field.values.get(rowIndex);
|
||||
const disp = field.display(raw);
|
||||
disp.toString = () => formattedValueToString(disp);
|
||||
return disp;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './overrides/processors';
|
||||
export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
|
||||
export { applyFieldOverrides, validateFieldConfig } from './fieldOverrides';
|
||||
export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from './string';
|
||||
export * from './markdown';
|
||||
export * from './text';
|
||||
import { escapeHtml, hasAnsiCodes, sanitize, sanitizeUrl } from './sanitize';
|
||||
|
||||
export const textUtil = {
|
||||
escapeHtml,
|
||||
hasAnsiCodes,
|
||||
sanitize,
|
||||
sanitizeUrl,
|
||||
};
|
||||
|
||||
44
packages/grafana-data/src/text/sanitize.ts
Normal file
44
packages/grafana-data/src/text/sanitize.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import xss from 'xss';
|
||||
import { sanitizeUrl as braintreeSanitizeUrl } from '@braintree/sanitize-url';
|
||||
|
||||
const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
|
||||
// @ts-ignore
|
||||
acc[element] = xss.whiteList[element].concat(['class', 'style']);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sanitizeXSS = new xss.FilterXSS({
|
||||
whiteList: XSSWL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns string safe from XSS attacks.
|
||||
*
|
||||
* Even though we allow the style-attribute, there's still default filtering applied to it
|
||||
* Info: https://github.com/leizongmin/js-xss#customize-css-filter
|
||||
* Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js
|
||||
*/
|
||||
export function sanitize(unsanitizedString: string): string {
|
||||
try {
|
||||
return sanitizeXSS.process(unsanitizedString);
|
||||
} catch (error) {
|
||||
console.log('String could not be sanitized', unsanitizedString);
|
||||
return unsanitizedString;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeUrl(url: string): string {
|
||||
return braintreeSanitizeUrl(url);
|
||||
}
|
||||
|
||||
export function hasAnsiCodes(input: string): boolean {
|
||||
return /\u001b\[\d{1,2}m/.test(input);
|
||||
}
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
100
packages/grafana-data/src/types/config.ts
Normal file
100
packages/grafana-data/src/types/config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { DataSourceInstanceSettings } from './datasource';
|
||||
import { PanelPluginMeta } from './panel';
|
||||
import { GrafanaTheme } from './theme';
|
||||
|
||||
/**
|
||||
* Describes the build information that will be available via the Grafana cofiguration.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
/**
|
||||
* Is set to true when running Grafana Enterprise edition.
|
||||
*
|
||||
* @deprecated use `licenseInfo.hasLicense` instead
|
||||
*/
|
||||
isEnterprise: boolean;
|
||||
env: string;
|
||||
edition: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes available feature toggles in Grafana. These can be configured via the
|
||||
* `conf/custom.ini` to enable features under development or not yet available in
|
||||
* stable version.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureToggles {
|
||||
transformations: boolean;
|
||||
expressions: boolean;
|
||||
newEdit: boolean;
|
||||
/**
|
||||
* @remarks
|
||||
* Available only in Grafana Enterprise
|
||||
*/
|
||||
meta: boolean;
|
||||
newVariables: boolean;
|
||||
tracingIntegration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the license information about the current running instance of Grafana.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface LicenseInfo {
|
||||
hasLicense: boolean;
|
||||
expiry: number;
|
||||
licenseUrl: string;
|
||||
stateInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes all the different Grafana configuration values available for an instance.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface GrafanaConfig {
|
||||
datasources: { [str: string]: DataSourceInstanceSettings };
|
||||
panels: { [key: string]: PanelPluginMeta };
|
||||
minRefreshInterval: string;
|
||||
appSubUrl: string;
|
||||
windowTitlePrefix: string;
|
||||
buildInfo: BuildInfo;
|
||||
newPanelTitle: string;
|
||||
bootData: any;
|
||||
externalUserMngLinkUrl: string;
|
||||
externalUserMngLinkName: string;
|
||||
externalUserMngInfo: string;
|
||||
allowOrgCreate: boolean;
|
||||
disableLoginForm: boolean;
|
||||
defaultDatasource: string;
|
||||
alertingEnabled: boolean;
|
||||
alertingErrorOrTimeout: string;
|
||||
alertingNoDataOrNullValues: string;
|
||||
alertingMinInterval: number;
|
||||
authProxyEnabled: boolean;
|
||||
exploreEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
samlEnabled: boolean;
|
||||
autoAssignOrg: boolean;
|
||||
verifyEmailEnabled: boolean;
|
||||
oauth: any;
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
passwordHint: any;
|
||||
loginError: any;
|
||||
navTree: any;
|
||||
viewersCanEdit: boolean;
|
||||
editorsCanAdmin: boolean;
|
||||
disableSanitizeHtml: boolean;
|
||||
theme: GrafanaTheme;
|
||||
pluginsToPreload: string[];
|
||||
featureToggles: FeatureToggles;
|
||||
licenseInfo: LicenseInfo;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ThresholdsConfig } from './thresholds';
|
||||
import { ValueMapping } from './valueMapping';
|
||||
import { QueryResultBase, Labels, NullValueMode } from './data';
|
||||
import { DisplayProcessor } from './displayValue';
|
||||
import { DataLink } from './dataLink';
|
||||
import { DisplayProcessor, DisplayValue } from './displayValue';
|
||||
import { DataLink, LinkModel } from './dataLink';
|
||||
import { Vector } from './vector';
|
||||
import { FieldCalcs } from '../transformations/fieldReducer';
|
||||
import { FieldColor } from './fieldColor';
|
||||
@@ -57,6 +57,17 @@ export interface FieldConfig<TOptions extends object = any> {
|
||||
scopedVars?: ScopedVars;
|
||||
}
|
||||
|
||||
export interface ValueLinkConfig {
|
||||
/**
|
||||
* Result of field reduction
|
||||
*/
|
||||
calculatedValue?: DisplayValue;
|
||||
/**
|
||||
* Index of the value row within Field. Should be provided only when value is not a result of a reduction
|
||||
*/
|
||||
valueRowIndex?: number;
|
||||
}
|
||||
|
||||
export interface Field<T = any, V = Vector<T>> {
|
||||
/**
|
||||
* Name of the field (column)
|
||||
@@ -87,6 +98,11 @@ export interface Field<T = any, V = Vector<T>> {
|
||||
* Convert a value for display
|
||||
*/
|
||||
display?: DisplayProcessor;
|
||||
|
||||
/**
|
||||
* Get value data links with variables interpolated
|
||||
*/
|
||||
getLinks?: (config: ValueLinkConfig) => Array<LinkModel<Field>>;
|
||||
}
|
||||
|
||||
export interface DataFrame extends QueryResultBase {
|
||||
|
||||
@@ -37,7 +37,7 @@ export type LinkTarget = '_blank' | '_self';
|
||||
/**
|
||||
* Processed Link Model. The values are ready to use
|
||||
*/
|
||||
export interface LinkModel<T> {
|
||||
export interface LinkModel<T = any> {
|
||||
href: string;
|
||||
title: string;
|
||||
target: LinkTarget;
|
||||
|
||||
@@ -31,3 +31,4 @@ export { AppEvent, AppEvents };
|
||||
|
||||
import * as PanelEvents from './panelEvents';
|
||||
export { PanelEvents };
|
||||
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
|
||||
|
||||
14
packages/grafana-data/src/utils/dataLinks.ts
Normal file
14
packages/grafana-data/src/utils/dataLinks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const DataLinkBuiltInVars = {
|
||||
keepTime: '__url_time_range',
|
||||
timeRangeFrom: '__from',
|
||||
timeRangeTo: '__to',
|
||||
includeVars: '__all_variables',
|
||||
seriesName: '__series.name',
|
||||
fieldName: '__field.name',
|
||||
valueTime: '__value.time',
|
||||
valueNumeric: '__value.numeric',
|
||||
valueText: '__value.text',
|
||||
valueRaw: '__value.raw',
|
||||
// name of the calculation represented by the value
|
||||
valueCalc: '__value.calc',
|
||||
};
|
||||
@@ -12,3 +12,6 @@ export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUI
|
||||
|
||||
export { getMappedValue } from './valueMappings';
|
||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
||||
export { locationUtil } from './location';
|
||||
export { urlUtil, UrlQueryMap, UrlQueryValue } from './url';
|
||||
export { DataLinkBuiltInVars } from './dataLinks';
|
||||
|
||||
21
packages/grafana-data/src/utils/location.test.ts
Normal file
21
packages/grafana-data/src/utils/location.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { locationUtil } from './location';
|
||||
|
||||
describe('locationUtil', () => {
|
||||
beforeAll(() => {
|
||||
locationUtil.initialize({
|
||||
getConfig: () => {
|
||||
return { appSubUrl: '/subUrl' } as any;
|
||||
},
|
||||
// @ts-ignore
|
||||
buildParamsFromVariables: () => {},
|
||||
// @ts-ignore
|
||||
getTimeRangeForUrl: () => {},
|
||||
});
|
||||
});
|
||||
describe('With /subUrl as appSubUrl', () => {
|
||||
it('/subUrl should be stripped', () => {
|
||||
const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
|
||||
expect(urlWithoutMaster).toBe('/grafana/');
|
||||
});
|
||||
});
|
||||
});
|
||||
73
packages/grafana-data/src/utils/location.ts
Normal file
73
packages/grafana-data/src/utils/location.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { GrafanaConfig, RawTimeRange, ScopedVars } from '../types';
|
||||
import { urlUtil } from './url';
|
||||
import { textUtil } from '../text';
|
||||
|
||||
let grafanaConfig: () => GrafanaConfig;
|
||||
let getTimeRangeUrlParams: () => RawTimeRange;
|
||||
let getVariablesUrlParams: (params?: Record<string, any>, scopedVars?: ScopedVars) => string;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url
|
||||
* @internal
|
||||
*/
|
||||
const stripBaseFromUrl = (url: string): string => {
|
||||
const appSubUrl = grafanaConfig ? grafanaConfig().appSubUrl : '';
|
||||
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
|
||||
const urlWithoutBase =
|
||||
url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
|
||||
|
||||
return urlWithoutBase;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url
|
||||
* @internal
|
||||
*/
|
||||
const assureBaseUrl = (url: string) => {
|
||||
if (url.startsWith('/')) {
|
||||
return `${grafanaConfig ? grafanaConfig().appSubUrl : ''}${stripBaseFromUrl(url)}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
interface LocationUtilDependencies {
|
||||
getConfig: () => GrafanaConfig;
|
||||
getTimeRangeForUrl: () => RawTimeRange;
|
||||
buildParamsFromVariables: (params: any, scopedVars?: ScopedVars) => string;
|
||||
}
|
||||
|
||||
export const locationUtil = {
|
||||
/**
|
||||
*
|
||||
* @param getConfig
|
||||
* @param buildParamsFromVariables
|
||||
* @param getTimeRangeForUrl
|
||||
* @internal
|
||||
*/
|
||||
initialize: ({ getConfig, buildParamsFromVariables, getTimeRangeForUrl }: LocationUtilDependencies) => {
|
||||
grafanaConfig = getConfig;
|
||||
getTimeRangeUrlParams = getTimeRangeForUrl;
|
||||
getVariablesUrlParams = buildParamsFromVariables;
|
||||
},
|
||||
stripBaseFromUrl,
|
||||
assureBaseUrl,
|
||||
getTimeRangeUrlParams: () => {
|
||||
if (!getTimeRangeUrlParams) {
|
||||
return null;
|
||||
}
|
||||
return urlUtil.toUrlParams(getTimeRangeUrlParams());
|
||||
},
|
||||
getVariablesUrlParams: (scopedVars?: ScopedVars) => {
|
||||
if (!getVariablesUrlParams) {
|
||||
return null;
|
||||
}
|
||||
const params = {};
|
||||
getVariablesUrlParams(params, scopedVars);
|
||||
return urlUtil.toUrlParams(params);
|
||||
},
|
||||
processUrl: (url: string) => {
|
||||
return grafanaConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
|
||||
},
|
||||
};
|
||||
25
packages/grafana-data/src/utils/url.test.ts
Normal file
25
packages/grafana-data/src/utils/url.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { urlUtil } from './url';
|
||||
|
||||
describe('toUrlParams', () => {
|
||||
it('should encode object properties as url parameters', () => {
|
||||
const url = urlUtil.toUrlParams({
|
||||
server: 'backend-01',
|
||||
hasSpace: 'has space',
|
||||
many: ['1', '2', '3'],
|
||||
true: true,
|
||||
number: 20,
|
||||
isNull: null,
|
||||
isUndefined: undefined,
|
||||
});
|
||||
expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUrlParams', () => {
|
||||
it('should encode the same way as angularjs', () => {
|
||||
const url = urlUtil.toUrlParams({
|
||||
server: ':@',
|
||||
});
|
||||
expect(url).toBe('server=:@');
|
||||
});
|
||||
});
|
||||
131
packages/grafana-data/src/utils/url.ts
Normal file
131
packages/grafana-data/src/utils/url.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type to represent the value of a single query variable.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[] | undefined | null;
|
||||
|
||||
/**
|
||||
* Type to represent the values parsed from the query string.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type UrlQueryMap = Record<string, UrlQueryValue>;
|
||||
|
||||
function renderUrl(path: string, query: UrlQueryMap | undefined): string {
|
||||
if (query && Object.keys(query).length > 0) {
|
||||
path += '?' + toUrlParams(query);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function encodeURIComponentAsAngularJS(val: string, pctEncodeSpaces?: boolean) {
|
||||
return encodeURIComponent(val)
|
||||
.replace(/%40/gi, '@')
|
||||
.replace(/%3A/gi, ':')
|
||||
.replace(/%24/g, '$')
|
||||
.replace(/%2C/gi, ',')
|
||||
.replace(/%3B/gi, ';')
|
||||
.replace(/%20/g, pctEncodeSpaces ? '%20' : '+');
|
||||
}
|
||||
|
||||
function toUrlParams(a: any) {
|
||||
const s: any[] = [];
|
||||
const rbracket = /\[\]$/;
|
||||
|
||||
const isArray = (obj: any) => {
|
||||
return Object.prototype.toString.call(obj) === '[object Array]';
|
||||
};
|
||||
|
||||
const add = (k: string, v: any) => {
|
||||
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
|
||||
if (typeof v !== 'boolean') {
|
||||
s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
|
||||
} else {
|
||||
s[s.length] = encodeURIComponentAsAngularJS(k, true);
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (prefix: string, obj: any) => {
|
||||
let i, len, key;
|
||||
|
||||
if (prefix) {
|
||||
if (isArray(obj)) {
|
||||
for (i = 0, len = obj.length; i < len; i++) {
|
||||
if (rbracket.test(prefix)) {
|
||||
add(prefix, obj[i]);
|
||||
} else {
|
||||
buildParams(prefix, obj[i]);
|
||||
}
|
||||
}
|
||||
} else if (obj && String(obj) === '[object Object]') {
|
||||
for (key in obj) {
|
||||
buildParams(prefix + '[' + key + ']', obj[key]);
|
||||
}
|
||||
} else {
|
||||
add(prefix, obj);
|
||||
}
|
||||
} else if (isArray(obj)) {
|
||||
for (i = 0, len = obj.length; i < len; i++) {
|
||||
add(obj[i].name, obj[i].value);
|
||||
}
|
||||
} else {
|
||||
for (key in obj) {
|
||||
buildParams(key, obj[key]);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
return buildParams('', a).join('&');
|
||||
}
|
||||
|
||||
function appendQueryToUrl(url: string, stringToAppend: string) {
|
||||
if (stringToAppend !== undefined && stringToAppend !== null && stringToAppend !== '') {
|
||||
const pos = url.indexOf('?');
|
||||
if (pos !== -1) {
|
||||
if (url.length - pos > 1) {
|
||||
url += '&';
|
||||
}
|
||||
} else {
|
||||
url += '?';
|
||||
}
|
||||
url += stringToAppend;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return search part (as object) of current url
|
||||
*/
|
||||
function getUrlSearchParams() {
|
||||
const search = window.location.search.substring(1);
|
||||
const searchParamsSegments = search.split('&');
|
||||
const params: any = {};
|
||||
for (const p of searchParamsSegments) {
|
||||
const keyValuePair = p.split('=');
|
||||
if (keyValuePair.length > 1) {
|
||||
// key-value param
|
||||
const key = decodeURIComponent(keyValuePair[0]);
|
||||
const value = decodeURIComponent(keyValuePair[1]);
|
||||
params[key] = value;
|
||||
} else if (keyValuePair.length === 1) {
|
||||
// boolean param
|
||||
const key = decodeURIComponent(keyValuePair[0]);
|
||||
params[key] = true;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export const urlUtil = {
|
||||
renderUrl,
|
||||
toUrlParams,
|
||||
appendQueryToUrl,
|
||||
getUrlSearchParams,
|
||||
};
|
||||
@@ -3,9 +3,9 @@
|
||||
"declarationDir": "dist",
|
||||
"outDir": "compiled",
|
||||
"rootDirs": ["."],
|
||||
"typeRoots": ["node_modules/@types"]
|
||||
"typeRoots": ["node_modules/@types", "types"]
|
||||
},
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"extends": "@grafana/tsconfig",
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/jquery/*.ts"]
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/jquery/*.ts", "../../public/app/types/sanitize-url.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,65 +1,17 @@
|
||||
import merge from 'lodash/merge';
|
||||
import { getTheme } from '@grafana/ui';
|
||||
import { DataSourceInstanceSettings, GrafanaTheme, GrafanaThemeType, PanelPluginMeta } from '@grafana/data';
|
||||
import {
|
||||
DataSourceInstanceSettings,
|
||||
GrafanaTheme,
|
||||
GrafanaThemeType,
|
||||
PanelPluginMeta,
|
||||
GrafanaConfig,
|
||||
LicenseInfo,
|
||||
BuildInfo,
|
||||
FeatureToggles,
|
||||
} from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Describes the build information that will be available via the Grafana cofiguration.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
/**
|
||||
* Is set to true when running Grafana Enterprise edition.
|
||||
*
|
||||
* @deprecated use `licenseInfo.hasLicense` instead
|
||||
*/
|
||||
isEnterprise: boolean;
|
||||
env: string;
|
||||
edition: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes available feature toggles in Grafana. These can be configured via the
|
||||
* `conf/custom.ini` to enable features under development or not yet available in
|
||||
* stable version.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureToggles {
|
||||
transformations: boolean;
|
||||
expressions: boolean;
|
||||
newEdit: boolean;
|
||||
/**
|
||||
* @remarks
|
||||
* Available only in Grafana Enterprise
|
||||
*/
|
||||
meta: boolean;
|
||||
newVariables: boolean;
|
||||
tracingIntegration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the license information about the current running instance of Grafana.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface LicenseInfo {
|
||||
hasLicense: boolean;
|
||||
expiry: number;
|
||||
licenseUrl: string;
|
||||
stateInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes all the different Grafana configuration values available for an instance.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class GrafanaBootConfig {
|
||||
export class GrafanaBootConfig implements GrafanaConfig {
|
||||
datasources: { [str: string]: DataSourceInstanceSettings } = {};
|
||||
panels: { [key: string]: PanelPluginMeta } = {};
|
||||
minRefreshInterval = '';
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
import { UrlQueryMap } from '@grafana/data';
|
||||
|
||||
export interface LocationUpdate {
|
||||
/**
|
||||
* Target path where you automatically wants to navigate the user.
|
||||
@@ -37,20 +39,6 @@ export interface LocationUpdate {
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type to represent the value of a single query variable.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[] | undefined | null;
|
||||
|
||||
/**
|
||||
* Type to represent the values parsed from the query string.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type UrlQueryMap = Record<string, UrlQueryValue>;
|
||||
|
||||
/**
|
||||
* If you need to automatically navigate the user to a new place in the application this should
|
||||
* be done via the LocationSrv and it will make sure to update the application state accordingly.
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"extends": "@grafana/tsconfig",
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/jquery/*.ts"]
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/jquery/*.ts", "../../public/app/types/sanitize-url.d.ts"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"extends": "@grafana/tsconfig",
|
||||
"include": ["src/**/*.ts", "../../public/app/types/jquery/*.ts"]
|
||||
"include": ["src/**/*.ts", "../../public/app/types/jquery/*.ts", "../../public/app/types/sanitize-url.d.ts"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
},
|
||||
"exclude": ["../dist", "../node_modules"],
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["../src/**/*.ts", "../src/**/*.tsx"]
|
||||
"include": ["../src/**/*.ts", "../src/**/*.tsx", "../../../public/app/types/sanitize-url.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useContext, useRef, RefObject, memo, useEffect } from 'react';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { DataLinkSuggestions } from './DataLinkSuggestions';
|
||||
import { ThemeContext, DataLinkBuiltInVars, makeValue } from '../../index';
|
||||
import { ThemeContext, makeValue } from '../../index';
|
||||
import { SelectionReference } from './SelectionReference';
|
||||
import { Portal } from '../index';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css } from 'emotion';
|
||||
import { SlatePrism } from '../../slate-plugins';
|
||||
import { SCHEMA } from '../../utils/slate';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { GrafanaTheme, VariableSuggestion, VariableOrigin } from '@grafana/data';
|
||||
import { GrafanaTheme, VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data';
|
||||
|
||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
|
||||
import { LinkModelSupplier } from '@grafana/data';
|
||||
import { LinkModel } from '@grafana/data';
|
||||
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
|
||||
import { css } from 'emotion';
|
||||
|
||||
interface DataLinksContextMenuProps {
|
||||
children: (props: { openMenu?: React.MouseEventHandler<HTMLElement>; targetClassName?: string }) => JSX.Element;
|
||||
links?: LinkModelSupplier<any>;
|
||||
links?: () => LinkModel[];
|
||||
}
|
||||
|
||||
export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ children, links }) => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from 'react';
|
||||
import { FieldConfigEditorProps, DataLink, DataLinksFieldConfigSettings } from '@grafana/data';
|
||||
import {
|
||||
DataLink,
|
||||
DataLinksFieldConfigSettings,
|
||||
FieldConfigEditorProps,
|
||||
VariableSuggestionsScope,
|
||||
} from '@grafana/data';
|
||||
import { DataLinksInlineEditor } from '../DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||
|
||||
export const DataLinksValueEditor: React.FC<FieldConfigEditorProps<DataLink[], DataLinksFieldConfigSettings>> = ({
|
||||
@@ -12,7 +17,7 @@ export const DataLinksValueEditor: React.FC<FieldConfigEditorProps<DataLink[], D
|
||||
links={value}
|
||||
onChange={onChange}
|
||||
data={context.data}
|
||||
suggestions={context.getSuggestions ? context.getSuggestions() : []}
|
||||
suggestions={context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import React, { FC } from 'react';
|
||||
import { TableCellProps } from './types';
|
||||
import { formattedValueToString } from '@grafana/data';
|
||||
import { formattedValueToString, LinkModel } from '@grafana/data';
|
||||
|
||||
export const DefaultCell: FC<TableCellProps> = props => {
|
||||
const { field, cell, tableStyles } = props;
|
||||
const { field, cell, tableStyles, row } = props;
|
||||
let link: LinkModel<any> | undefined;
|
||||
|
||||
if (!field.display) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayValue = field.display(cell.value);
|
||||
return <div className={tableStyles.tableCell}>{formattedValueToString(displayValue)}</div>;
|
||||
|
||||
if (field.getLinks) {
|
||||
link = field.getLinks({
|
||||
valueRowIndex: row.index,
|
||||
})[0];
|
||||
}
|
||||
const value = formattedValueToString(displayValue);
|
||||
|
||||
return (
|
||||
<div className={tableStyles.tableCell}>
|
||||
{link ? (
|
||||
<a href={link.href} target={link.target} title={link.title}>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
|
||||
import { LinkModelSupplier } from '@grafana/data';
|
||||
|
||||
export const DataLinkBuiltInVars = {
|
||||
keepTime: '__url_time_range',
|
||||
timeRangeFrom: '__from',
|
||||
timeRangeTo: '__to',
|
||||
includeVars: '__all_variables',
|
||||
seriesName: '__series.name',
|
||||
fieldName: '__field.name',
|
||||
valueTime: '__value.time',
|
||||
valueNumeric: '__value.numeric',
|
||||
valueText: '__value.text',
|
||||
valueRaw: '__value.raw',
|
||||
// name of the calculation represented by the value
|
||||
valueCalc: '__value.calc',
|
||||
};
|
||||
import { LinkModel } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Delays creating links until we need to open the ContextMenu
|
||||
*/
|
||||
export const linkModelToContextMenuItems: (links: LinkModelSupplier<any>) => ContextMenuItem[] = links => {
|
||||
return links.getLinks().map(link => {
|
||||
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => ContextMenuItem[] = links => {
|
||||
return links().map(link => {
|
||||
return {
|
||||
label: link.title,
|
||||
// TODO: rename to href
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"extends": "@grafana/tsconfig",
|
||||
"include": ["src/**/*.ts*"]
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/sanitize-url.d.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user