From c8658f3ee8408d3e7db7615740a30d3d5cfc1f90 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 15 Oct 2020 00:03:14 +0200 Subject: [PATCH] Explore: Add link to logs from trace span (#28229) * Add trace to logs link * Do a bit of refactor and allow for custom time range in split * Add margin and noopener to the link * Fix tests * Fix tests --- packages/grafana-data/src/types/config.ts | 2 + packages/grafana-data/src/types/trace.ts | 1 + packages/grafana-data/src/utils/dataLinks.ts | 4 +- packages/grafana-data/src/utils/index.ts | 2 +- packages/grafana-runtime/src/config.ts | 1 + .../src/services/dataSourceSrv.ts | 10 ++ .../src/TraceTimelineViewer/SpanBarRow.tsx | 30 ++++++ .../VirtualizedTraceView.tsx | 5 + .../src/TraceTimelineViewer/index.tsx | 3 + .../getAlertingValidationMessage.test.ts | 32 ++++++- public/app/features/explore/Explore.test.tsx | 1 + public/app/features/explore/Explore.tsx | 9 +- .../explore/TraceView/TraceView.test.tsx | 2 +- .../features/explore/TraceView/TraceView.tsx | 5 + .../explore/TraceView/createSpanLink.test.ts | 88 ++++++++++++++++++ .../explore/TraceView/createSpanLink.tsx | 92 +++++++++++++++++++ public/app/features/explore/state/actions.ts | 11 ++- .../app/features/explore/utils/links.test.ts | 10 +- public/app/features/explore/utils/links.ts | 3 +- 19 files changed, 300 insertions(+), 11 deletions(-) create mode 100644 public/app/features/explore/TraceView/createSpanLink.test.ts create mode 100644 public/app/features/explore/TraceView/createSpanLink.tsx diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 7c0c449c9d4..c41a35ae48a 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -35,6 +35,8 @@ export interface FeatureToggles { live: boolean; expressions: boolean; ngalert: boolean; + // Just for demo at the moment + traceToLogs: boolean; /** * @remarks diff --git a/packages/grafana-data/src/types/trace.ts b/packages/grafana-data/src/types/trace.ts index 5b63a86505b..63b40d1d706 100644 --- a/packages/grafana-data/src/types/trace.ts +++ b/packages/grafana-data/src/types/trace.ts @@ -51,6 +51,7 @@ export type TraceSpanData = { traceID: string; processID: string; operationName: string; + // Times are in microseconds startTime: number; duration: number; logs: TraceLog[]; diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index c80da690f80..0783f59966d 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -26,8 +26,9 @@ export const DataLinkBuiltInVars = { valueCalc: '__value.calc', }; +// We inject these because we cannot import them directly as they reside inside grafana main package. type Options = { - onClickFn?: (options: { datasourceUid: string; query: any }) => void; + onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void; replaceVariables: InterpolateFunction; getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined; }; @@ -62,6 +63,7 @@ export function mapInternalLinkToExplore( onClickFn?.({ datasourceUid: link.internal!.datasourceUid, query: interpolatedQuery, + range, }); } : undefined, diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index d142b1c9ab4..5d7f7ff8202 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -15,6 +15,6 @@ export { getMappedValue } from './valueMappings'; export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; export { locationUtil } from './location'; export { urlUtil, UrlQueryMap, UrlQueryValue } from './url'; -export { DataLinkBuiltInVars } from './dataLinks'; +export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks'; export { DocsId } from './docs'; export { observableTester } from './tests/observableTester'; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 0930ec44371..8f1cff5abbd 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -56,6 +56,7 @@ export class GrafanaBootConfig implements GrafanaConfig { expressions: false, meta: false, ngalert: false, + traceToLogs: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 00a3060bc15..0abf84952cc 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -19,6 +19,16 @@ export interface DataSourceSrv { * Returns metadata based on UID. */ getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined; + + /** + * Get all data sources + */ + getAll(): DataSourceInstanceSettings[]; + + /** + * Get all data sources except for internal ones that usually should not be listed like mixed data source. + */ + getExternal(): DataSourceInstanceSettings[]; } let singletonInstance: DataSourceSrv; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx index 34b2ad3362b..5357de3637e 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -310,6 +310,9 @@ type SpanBarRowProps = { removeHoverIndentGuideId: (spanID: string) => void; clippingLeft?: boolean; clippingRight?: boolean; + createSpanLink?: ( + span: TraceSpan + ) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode }; }; /** @@ -356,6 +359,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { clippingLeft, clippingRight, theme, + createSpanLink, } = this.props; const { duration, @@ -437,6 +441,32 @@ export class UnthemedSpanBarRow extends React.PureComponent { {rpc ? rpc.operationName : operationName} + {createSpanLink && + (() => { + const link = createSpanLink(span); + return ( + { + if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) { + event.preventDefault(); + link.onClick(event); + } + } + : undefined + } + > + {link.content} + + ); + })()} + {span.references && span.references.length > 1 && ( void; removeHoverIndentGuideId: (spanID: string) => void; theme: Theme; + createSpanLink?: ( + span: TraceSpan + ) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode }; }; type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline; @@ -330,6 +333,7 @@ export class UnthemedVirtualizedTraceView extends React.Component ); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx index 96f84edb847..be668aaa066 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx @@ -99,6 +99,9 @@ type TProps = TExtractUiFindFromStateReturn & { removeHoverIndentGuideId: (spanID: string) => void; linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[]; theme: Theme; + createSpanLink?: ( + span: TraceSpan + ) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode }; }; type State = { diff --git a/public/app/features/alerting/getAlertingValidationMessage.test.ts b/public/app/features/alerting/getAlertingValidationMessage.test.ts index ec53d846571..f98a765975c 100644 --- a/public/app/features/alerting/getAlertingValidationMessage.test.ts +++ b/public/app/features/alerting/getAlertingValidationMessage.test.ts @@ -1,5 +1,5 @@ import { DataSourceSrv } from '@grafana/runtime'; -import { DataSourceApi, PluginMeta, DataTransformerConfig } from '@grafana/data'; +import { DataSourceApi, PluginMeta, DataTransformerConfig, DataSourceInstanceSettings } from '@grafana/data'; import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types'; import { getAlertingValidationMessage } from './getAlertingValidationMessage'; @@ -23,6 +23,12 @@ describe('getAlertingValidationMessage', () => { const datasourceSrv: DataSourceSrv = { get: getMock, getDataSourceSettingsByUid(): any {}, + getExternal(): DataSourceInstanceSettings[] { + return []; + }, + getAll(): DataSourceInstanceSettings[] { + return []; + }, }; const targets: ElasticsearchQuery[] = [ { refId: 'A', query: '@hostname:$hostname', isLogsQuery: false }, @@ -60,6 +66,12 @@ describe('getAlertingValidationMessage', () => { return Promise.resolve(alertingDatasource); }, getDataSourceSettingsByUid(): any {}, + getExternal(): DataSourceInstanceSettings[] { + return []; + }, + getAll(): DataSourceInstanceSettings[] { + return []; + }, }; const targets: any[] = [ { refId: 'A', query: 'some query', datasource: 'alertingDatasource' }, @@ -84,6 +96,12 @@ describe('getAlertingValidationMessage', () => { const datasourceSrv: DataSourceSrv = { get: getMock, getDataSourceSettingsByUid(): any {}, + getExternal(): DataSourceInstanceSettings[] { + return []; + }, + getAll(): DataSourceInstanceSettings[] { + return []; + }, }; const targets: ElasticsearchQuery[] = [ { refId: 'A', query: '@hostname:$hostname', isLogsQuery: false }, @@ -110,6 +128,12 @@ describe('getAlertingValidationMessage', () => { const datasourceSrv: DataSourceSrv = { get: getMock, getDataSourceSettingsByUid(): any {}, + getExternal(): DataSourceInstanceSettings[] { + return []; + }, + getAll(): DataSourceInstanceSettings[] { + return []; + }, }; const targets: ElasticsearchQuery[] = [ { refId: 'A', query: '@hostname:hostname', isLogsQuery: false }, @@ -136,6 +160,12 @@ describe('getAlertingValidationMessage', () => { const datasourceSrv: DataSourceSrv = { get: getMock, getDataSourceSettingsByUid(): any {}, + getExternal(): DataSourceInstanceSettings[] { + return []; + }, + getAll(): DataSourceInstanceSettings[] { + return []; + }, }; const targets: ElasticsearchQuery[] = [ { refId: 'A', query: '@hostname:hostname', isLogsQuery: false }, diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index a19f4c981d3..14fe431e9aa 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -101,6 +101,7 @@ const dummyProps: ExploreProps = { showLogs: true, showTable: true, showTrace: true, + splitOpen: (() => {}) as any, }; const setupErrors = (hasRefId?: boolean) => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index cb7c3e3b7ba..cba993e8bb6 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -37,6 +37,7 @@ import { scanStart, setQueries, updateTimeRange, + splitOpen, } from './state/actions'; import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore'; @@ -120,6 +121,7 @@ export interface ExploreProps { showTable: boolean; showLogs: boolean; showTrace: boolean; + splitOpen: typeof splitOpen; } enum ExploreDrawer { @@ -309,6 +311,7 @@ export class Explore extends React.PureComponent { showTable, showLogs, showTrace, + splitOpen, } = this.props; const { openDrawer } = this.state; const exploreClass = split ? 'explore explore-split' : 'explore'; @@ -405,7 +408,10 @@ export class Explore extends React.PureComponent { // We expect only one trace at the moment to be in the dataframe // If there is not data (like 404) we show a separate error so no need to show anything here queryResponse.series[0] && ( - + )} )} @@ -505,6 +511,7 @@ const mapDispatchToProps: Partial = { setQueries, updateTimeRange, addQueryRow, + splitOpen, }; export default compose( diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 58dd1f3597a..4b44e4355bf 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -5,7 +5,7 @@ import { TracePageHeader, TraceTimelineViewer } from '@jaegertracing/jaeger-ui-c import { TraceSpanData, TraceData } from '@grafana/data'; function renderTraceView() { - const wrapper = shallow(); + const wrapper = shallow( {}} />); return { timeline: wrapper.find(TraceTimelineViewer), header: wrapper.find(TracePageHeader), diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index a49c000f821..b963e1376d2 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -17,9 +17,11 @@ import { useDetailState } from './useDetailState'; import { useHoverIndentGuide } from './useHoverIndentGuide'; import { colors, useTheme } from '@grafana/ui'; import { TraceData, TraceSpanData, Trace, TraceSpan, TraceKeyValuePair, TraceLink } from '@grafana/data'; +import { createSpanLinkFactory } from './createSpanLink'; type Props = { trace: TraceData & { spans: TraceSpanData[] }; + splitOpenFn: (options: { datasourceUid: string; query: any }) => void; }; export function TraceView(props: Props) { @@ -77,6 +79,8 @@ export function TraceView(props: Props) { [childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID] ); + const createSpanLink = useMemo(() => createSpanLinkFactory(props.splitOpenFn), [props.splitOpenFn]); + if (!traceProp) { return null; } @@ -140,6 +144,7 @@ export function TraceView(props: Props) { [] )} uiFind={search} + createSpanLink={createSpanLink} /> diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts new file mode 100644 index 00000000000..8cfb3cea64f --- /dev/null +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -0,0 +1,88 @@ +import { createSpanLinkFactory } from './createSpanLink'; +import { config, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime'; +import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; + +describe('createSpanLinkFactory', () => { + beforeAll(() => { + config.featureToggles.traceToLogs = true; + }); + + afterAll(() => { + config.featureToggles.traceToLogs = false; + }); + + it('returns undefined if there is no loki data source', () => { + setDataSourceSrv({ + getExternal() { + return [ + { + meta: { + id: 'not loki', + }, + } as DataSourceInstanceSettings, + ]; + }, + } as any); + const splitOpenFn = jest.fn(); + const createLink = createSpanLinkFactory(splitOpenFn); + expect(createLink).not.toBeDefined(); + }); + + it('creates correct link', () => { + setDataSourceSrv({ + getExternal() { + return [ + { + name: 'loki1', + uid: 'lokiUid', + meta: { + id: 'loki', + }, + } as DataSourceInstanceSettings, + ]; + }, + getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined { + if (uid === 'lokiUid') { + return { + name: 'Loki1', + } as any; + } + return undefined; + }, + } as any); + + setTemplateSrv({ + replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string { + return target!; + }, + } as any); + + const splitOpenFn = jest.fn(); + const createLink = createSpanLinkFactory(splitOpenFn); + expect(createLink).toBeDefined(); + const linkDef = createLink!({ + startTime: new Date('2020-10-14T01:00:00Z').valueOf() * 1000, + duration: 1000 * 1000, + process: { + tags: [ + { + key: 'cluster', + value: 'cluster1', + }, + { + key: 'hostname', + value: 'hostname1', + }, + { + key: 'label2', + value: 'val2', + }, + ], + } as any, + } as any); + + expect(linkDef.href).toBe( + `/explore?left={"range":{"from":"20201014T005955","to":"20201014T020001"},"datasource":"Loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}` + ); + }); +}); diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx new file mode 100644 index 00000000000..3fc3a52e816 --- /dev/null +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { config, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; +import { DataLink, dateTime, Field, mapInternalLinkToExplore, TimeRange, TraceSpan } from '@grafana/data'; +import { LokiQuery } from '../../../plugins/datasource/loki/types'; +import { Icon } from '@grafana/ui'; + +/** + * This is a factory for the link creator. It returns the function mainly so it can return undefined in which case + * the trace view won't create any links and to capture the datasource and split function making it easier to memoize + * with useMemo. + */ +export function createSpanLinkFactory(splitOpenFn: (options: { datasourceUid: string; query: any }) => void) { + if (!config.featureToggles.traceToLogs) { + return undefined; + } + + // Right now just hardcoded for first loki DS we can find + const lokiDs = getDataSourceSrv() + .getExternal() + .find(ds => ds.meta.id === 'loki'); + + if (!lokiDs) { + return undefined; + } + + return function(span: TraceSpan): { href: string; onClick?: (event: any) => void; content: React.ReactNode } { + // This is reusing existing code from derived fields which may not be ideal match so some data is a bit faked at + // the moment. Issue is that the trace itself isn't clearly mapped to dataFrame (right now it's just a json blob + // inside a single field) so the dataLinks as config of that dataFrame abstraction breaks down a bit and we do + // it manually here instead of leaving it for the data source to supply the config. + + const dataLink: DataLink = { + title: lokiDs.name, + url: '', + internal: { + datasourceUid: lokiDs.uid, + query: { + expr: getLokiQueryFromSpan(span), + refId: '', + }, + }, + }; + const link = mapInternalLinkToExplore(dataLink, {}, getTimeRangeFromSpan(span), {} as Field, { + onClickFn: splitOpenFn, + replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), + getDataSourceSettingsByUid: getDataSourceSrv().getDataSourceSettingsByUid.bind(getDataSourceSrv()), + }); + return { + href: link.href, + onClick: link.onClick, + content: , + }; + }; +} + +/** + * Right now this is just hardcoded and later will probably be part of some user configuration. + */ +const allowedKeys = ['cluster', 'hostname', 'namespace', 'pod']; + +function getLokiQueryFromSpan(span: TraceSpan): string { + const tags = span.process.tags.reduce((acc, tag) => { + if (allowedKeys.includes(tag.key)) { + acc.push(`${tag.key}="${tag.value}"`); + } + return acc; + }, [] as string[]); + return `{${tags.join(', ')}}`; +} + +/** + * Gets a time range from the span. Naively this could be just start and end time of the span but we also want some + * buffer around that just so we do not miss some logs which may not have timestamps aligned with the span. Right + * now the buffers are hardcoded which may be a bit weird for very short spans but at the same time, fractional buffers + * with very short spans could mean microseconds and that could miss some logs relevant to that spans. In the future + * something more intelligent should probably be implemented + */ +function getTimeRangeFromSpan(span: TraceSpan): TimeRange { + const from = dateTime(span.startTime / 1000 - 5 * 1000); + const spanEndMs = (span.startTime + span.duration) / 1000; + const to = dateTime(spanEndMs + 1000 * 60 * 60); + return { + from, + to, + // Weirdly Explore does not handle ISO string which would have been the default stringification if passed as object + // and we have to use this custom format :( . + raw: { + from: from.format('YYYYMMDDTHHmmss'), + to: to.format('YYYYMMDDTHHmmss'), + }, + }; +} diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index a93545e7678..5c8f739bec7 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -678,7 +678,11 @@ export function splitClose(itemId: ExploreId): ThunkResult { * Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query * results. */ -export function splitOpen(options?: { datasourceUid: string; query: T }): ThunkResult { +export function splitOpen(options?: { + datasourceUid: string; + query: T; + range?: TimeRange; +}): ThunkResult { return async (dispatch, getState) => { // Clone left state to become the right state const leftState: ExploreItemState = getState().explore[ExploreId.left]; @@ -696,6 +700,10 @@ export function splitOpen(options?: { datasourceUid: rightState.queryKeys = []; urlState.queries = []; rightState.urlState = urlState; + if (options.range) { + urlState.range = options.range.raw; + rightState.range = options.range; + } dispatch(splitOpenAction({ itemState: rightState })); @@ -707,6 +715,7 @@ export function splitOpen(options?: { datasourceUid: ]; const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.datasourceUid); + await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name)); await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries })); await dispatch(runQueries(ExploreId.right)); diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index c4d376a737e..8d1c66c96be 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -57,7 +57,11 @@ describe('getFieldLinksForExplore', () => { links[0].onClick({}); } - expect(splitfn).toBeCalledWith({ datasourceUid: 'uid_1', query: { query: 'query_1' } }); + expect(splitfn).toBeCalledWith({ + datasourceUid: 'uid_1', + query: { query: 'query_1' }, + range, + }); }); }); @@ -100,8 +104,8 @@ function setup(link: DataLink) { }; const range: TimeRange = { - from: dateTime(), - to: dateTime(), + from: dateTime('2020-10-14T00:00:00'), + to: dateTime('2020-10-14T01:00:00'), raw: { from: 'now-1h', to: 'now', diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index a5e490e00f4..8d27dd3dfff 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -1,8 +1,7 @@ import memoizeOne from 'memoize-one'; import { splitOpen } from '../state/actions'; -import { Field, LinkModel, TimeRange } from '@grafana/data'; +import { Field, LinkModel, TimeRange, mapInternalLinkToExplore } from '@grafana/data'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; -import { mapInternalLinkToExplore } from '@grafana/data/src/utils/dataLinks'; import { getDataSourceSrv, getTemplateSrv, getBackendSrv, config } from '@grafana/runtime'; /**