From a6ff50300e10814c4b1027c4d744c95ded211d55 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Wed, 30 Aug 2023 10:48:39 +0200 Subject: [PATCH] Pyroscope: Template variable support (#73572) --- .../src/components/Cascader/Cascader.test.tsx | 2 +- .../src/components/Cascader/Cascader.tsx | 8 +- .../QueryEditor/LabelsEditor.tsx | 6 +- .../QueryEditor/ProfileTypesCascader.tsx | 86 ++++++++++ .../QueryEditor/QueryEditor.test.tsx | 2 +- .../QueryEditor/QueryEditor.tsx | 122 +++---------- .../VariableQueryEditor.test.tsx | 94 ++++++++++ .../VariableQueryEditor.tsx | 162 ++++++++++++++++++ .../VariableSupport.test.ts | 73 ++++++++ .../VariableSupport.ts | 75 ++++++++ .../datasource.test.ts | 38 ++-- .../datasource.ts | 14 +- .../grafana-pyroscope-datasource/types.ts | 20 +++ 13 files changed, 577 insertions(+), 125 deletions(-) create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts create mode 100644 public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx index d70e1b5c362..e39b7504360 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx @@ -122,7 +122,7 @@ describe('Cascader', () => { await userEvent.click(screen.getByText('First'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); await userEvent.click(screen.getByText('Second'), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - expect(screen.getByDisplayValue('First/Second')).toBeInTheDocument(); + expect(screen.getByDisplayValue('First / Second')).toBeInTheDocument(); }); it('displays all levels selected with separator passed in when displayAllSelectedLevels is true', async () => { diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx index 844f40ab2d2..9e89a7da52b 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -15,12 +15,14 @@ export interface CascaderProps { /** The separator between levels in the search */ separator?: string; placeholder?: string; + /** As the onSelect handler reports only the leaf node selected, the leaf nodes should have unique value. */ options: CascaderOption[]; /** Changes the value for every selection, including branch nodes. Defaults to true. */ changeOnSelect?: boolean; onSelect(val: string): void; /** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/ width?: number; + /** Single string that needs to be the same as value of the last item in the selection chain. */ initialValue?: string; allowCustomValue?: boolean; /** A function for formatting the message for custom value creation. Only applies when allowCustomValue is set to true*/ @@ -69,7 +71,7 @@ const disableDivFocus = css({ }, }); -const DEFAULT_SEPARATOR = '/'; +const DEFAULT_SEPARATOR = ' / '; export class Cascader extends PureComponent { constructor(props: CascaderProps) { @@ -94,7 +96,7 @@ export class Cascader extends PureComponent { if (!option.items) { selectOptions.push({ singleLabel: cpy[cpy.length - 1].label, - label: cpy.map((o) => o.label).join(this.props.separator || ` ${DEFAULT_SEPARATOR} `), + label: cpy.map((o) => o.label).join(this.props.separator || DEFAULT_SEPARATOR), value: cpy.map((o) => o.value), }); } else { @@ -113,7 +115,7 @@ export class Cascader extends PureComponent { for (const option of searchableOptions) { const optionPath = option.value || []; - if (optionPath.indexOf(initValue) === optionPath.length - 1) { + if (optionPath[optionPath.length - 1] === initValue) { return { rcValue: optionPath, activeLabel: this.props.displayAllSelectedLevels ? option.label : option.singleLabel || '', diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/LabelsEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/LabelsEditor.tsx index 4846b59bc3b..31f005de2de 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/LabelsEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/LabelsEditor.tsx @@ -49,8 +49,8 @@ export function LabelsEditor(props: Props) { scrollBeyondLastLine: false, wordWrap: 'on', padding: { - top: 5, - bottom: 6, + top: 4, + bottom: 5, }, }} onBeforeEditorMount={ensurePhlareQL} @@ -141,6 +141,7 @@ function ensurePhlareQL(monaco: Monaco) { const getStyles = () => { return { queryField: css` + label: LabelsEditorQueryField; flex: 1; // Not exactly sure but without this the editor does not shrink after resizing (so you can make it bigger but not // smaller). At the same time this does not actually make the editor 100px because it has flex 1 so I assume @@ -148,6 +149,7 @@ const getStyles = () => { width: 100px; `, wrapper: css` + label: LabelsEditorWrapper; display: flex; flex: 1; border: 1px solid rgba(36, 41, 46, 0.3); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx new file mode 100644 index 00000000000..7708cb1862a --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Cascader, CascaderOption } from '@grafana/ui'; + +import { PhlareDataSource } from '../datasource'; +import { ProfileTypeMessage } from '../types'; + +type Props = { + initialProfileTypeId?: string; + profileTypes?: ProfileTypeMessage[]; + onChange: (value: string) => void; +}; + +export function ProfileTypesCascader(props: Props) { + const cascaderOptions = useCascaderOptions(props.profileTypes); + + return ( + + ); +} + +// Turn profileTypes into cascader options +function useCascaderOptions(profileTypes?: ProfileTypeMessage[]): CascaderOption[] { + return useMemo(() => { + if (!profileTypes) { + return []; + } + let mainTypes = new Map(); + // Classify profile types by name then sample type. + // The profileTypes are something like cpu:sample:nanoseconds:sample:count or app.something.something + for (let profileType of profileTypes) { + let parts: string[]; + // Phlare uses : as delimiter while Pyro uses . + if (profileType.id.indexOf(':') > -1) { + parts = profileType.id.split(':'); + } else { + parts = profileType.id.split('.'); + const last = parts.pop()!; + parts = [parts.join('.'), last]; + } + + const [name, type] = parts; + + if (!mainTypes.has(name)) { + mainTypes.set(name, { + label: name, + value: name, + items: [], + }); + } + mainTypes.get(name)?.items!.push({ + label: type, + value: profileType.id, + }); + } + return Array.from(mainTypes.values()); + }, [profileTypes]); +} + +/** + * Loads the profile types. + * + * This is exported and not used directly in the ProfileTypesCascader component because in some case we need to know + * the profileTypes before rendering the cascader. + * @param datasource + */ +export function useProfileTypes(datasource: PhlareDataSource) { + const [profileTypes, setProfileTypes] = useState(); + + useEffect(() => { + (async () => { + const profileTypes = await datasource.getProfileTypes(); + setProfileTypes(profileTypes); + })(); + }, [datasource]); + + return profileTypes; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index 728d5060c20..fd3349b953d 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -13,7 +13,7 @@ describe('QueryEditor', () => { it('should render without error', async () => { setup(); - expect(await screen.findByText('process_cpu - cpu')).toBeDefined(); + expect(await screen.findByDisplayValue('process_cpu-cpu')).toBeDefined(); }); it('should render options', async () => { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx index c95c6f7e95a..28379aee471 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx @@ -1,16 +1,17 @@ import deepEqual from 'fast-deep-equal'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useAsync } from 'react-use'; import { CoreApp, QueryEditorProps, TimeRange } from '@grafana/data'; -import { ButtonCascader, CascaderOption } from '@grafana/ui'; +import { LoadingPlaceholder } from '@grafana/ui'; import { normalizeQuery, PhlareDataSource } from '../datasource'; -import { BackendType, PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types'; +import { PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types'; import { EditorRow } from './EditorRow'; import { EditorRows } from './EditorRows'; import { LabelsEditor } from './LabelsEditor'; +import { ProfileTypesCascader, useProfileTypes } from './ProfileTypesCascader'; import { QueryOptions } from './QueryOptions'; export type Props = QueryEditorProps; @@ -23,18 +24,29 @@ export function QueryEditor(props: Props) { onRunQuery(); } - const { profileTypes, onProfileTypeChange, selectedProfileName } = useProfileTypes(datasource, query, onChange); + const profileTypes = useProfileTypes(datasource); const { labels, getLabelValues, onLabelSelectorChange } = useLabels(range, datasource, query, onChange); useNormalizeQuery(query, profileTypes, onChange, app); - const cascaderOptions = useCascaderOptions(profileTypes); - return ( - - {selectedProfileName} - + {/* + The cascader is uncontrolled component so if we want to set some default value we can do it only on initial + render, so we are waiting until we have the profileTypes and know what the default value should be before + rendering. + */} + {profileTypes && query.profileTypeId ? ( + { + onChange({ ...query, profileTypeId: val }); + }} + /> + ) : ( + + )} void, app?: CoreApp ) { useEffect(() => { + if (!profileTypes) { + return; + } const normalizedQuery = normalizeQuery(query, app); - // Query can be stored with some old type, or we can have query from different pyro datasource - const selectedProfile = query.profileTypeId && profileTypes.find((p) => p.id === query.profileTypeId); - if (profileTypes.length && !selectedProfile) { + // We just check if profileTypeId is filled but don't check if it's one of the existing cause it can be template + // variable + if (!query.profileTypeId) { normalizedQuery.profileTypeId = defaultProfileType(profileTypes); } // Makes sure we don't have an infinite loop updates because the normalization creates a new object @@ -125,84 +140,3 @@ function useLabels( return { labels: labelsResult.value, getLabelValues, onLabelSelectorChange }; } - -// Turn profileTypes into cascader options -function useCascaderOptions(profileTypes: ProfileTypeMessage[]) { - return useMemo(() => { - let mainTypes = new Map(); - // Classify profile types by name then sample type. - for (let profileType of profileTypes) { - let parts: string[]; - // Phlare uses : as delimiter while Pyro uses . - if (profileType.id.indexOf(':') > -1) { - parts = profileType.id.split(':'); - } else { - parts = profileType.id.split('.'); - const last = parts.pop()!; - parts = [parts.join('.'), last]; - } - - const [name, type] = parts; - - if (!mainTypes.has(name)) { - mainTypes.set(name, { - label: name, - value: profileType.id, - children: [], - }); - } - mainTypes.get(name)?.children?.push({ - label: type, - value: profileType.id, - }); - } - return Array.from(mainTypes.values()); - }, [profileTypes]); -} - -function useProfileTypes(datasource: PhlareDataSource, query: Query, onChange: (value: Query) => void) { - const [profileTypes, setProfileTypes] = useState([]); - - useEffect(() => { - (async () => { - const profileTypes = await datasource.getProfileTypes(); - setProfileTypes(profileTypes); - })(); - }, [datasource]); - - const onProfileTypeChange = useCallback( - (value: string[], selectedOptions: CascaderOption[]) => { - if (selectedOptions.length === 0) { - return; - } - - const id = selectedOptions[selectedOptions.length - 1].value; - onChange({ ...query, profileTypeId: id }); - }, - [onChange, query] - ); - - const selectedProfileName = useProfileName(profileTypes, query.profileTypeId, datasource.backendType); - return { profileTypes, onProfileTypeChange, selectedProfileName }; -} - -function useProfileName( - profileTypes: ProfileTypeMessage[], - profileTypeId: string, - backendType: BackendType = 'phlare' -) { - return useMemo(() => { - if (!profileTypes) { - return 'Loading'; - } - const profile = profileTypes.find((type) => type.id === profileTypeId); - if (!profile) { - if (backendType === 'pyroscope') { - return 'Select application'; - } - return 'Select a profile type'; - } - - return profile.label; - }, [profileTypeId, profileTypes, backendType]); -} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.test.tsx new file mode 100644 index 00000000000..6b51d2c58a3 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + +import { VariableQueryEditor } from './VariableQueryEditor'; +import { PhlareDataSource } from './datasource'; +import { PhlareDataSourceOptions } from './types'; + +describe('VariableQueryEditor', () => { + it('renders correctly with type profileType', () => { + render( + {}} + onChange={() => {}} + /> + ); + + expect(screen.getByLabelText(/Query type/)).toBeInTheDocument(); + }); + + it('renders correctly with type labels', async () => { + render( + {}} + onChange={() => {}} + /> + ); + + expect(screen.getByLabelText(/Query type/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Profile type/)).toBeInTheDocument(); + expect(await screen.findByDisplayValue(/profile-type/)).toBeInTheDocument(); + }); + + it('renders correctly with type labelValue', async () => { + render( + {}} + onChange={() => {}} + /> + ); + + expect(screen.getByLabelText(/Query type/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Profile type/)).toBeInTheDocument(); + // using custom value for change + expect(await screen.findByDisplayValue(/cpu/)).toBeInTheDocument(); + expect(screen.getByText('Label')).toBeInTheDocument(); + expect(await screen.findByText(/foo/)).toBeInTheDocument(); + }); +}); + +function getMockDatasource() { + const ds = new PhlareDataSource( + { + jsonData: { backendType: 'phlare' }, + } as DataSourceInstanceSettings, + new TemplateSrv() + ); + ds.getResource = jest.fn(); + (ds.getResource as jest.Mock).mockImplementation(async (type: string) => { + if (type === 'profileTypes') { + return [ + { label: 'profile type 1', id: 'profile:type:1' }, + { label: 'profile type 2', id: 'profile:type:2' }, + { label: 'profile type 3', id: 'profile:type:3' }, + ]; + } + if (type === 'labelNames') { + return ['foo', 'bar']; + } + + return ['val1', 'val2']; + }); + return ds; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx new file mode 100644 index 00000000000..4a519c85dbe --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableQueryEditor.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; + +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { InlineField, InlineFieldRow, LoadingPlaceholder, Select } from '@grafana/ui'; + +import { ProfileTypesCascader, useProfileTypes } from './QueryEditor/ProfileTypesCascader'; +import { PhlareDataSource } from './datasource'; +import { Query, VariableQuery } from './types'; + +export function VariableQueryEditor(props: QueryEditorProps) { + return ( + <> + + The Prometheus data source plugin provides the following query types for template variables + } + > + props.onChange(option.value)} + value={props.value} + /> + + + ); +} + +function ProfileTypeRow(props: { + datasource: PhlareDataSource; + onChange: (val: string) => void; + initialValue?: string; +}) { + const profileTypes = useProfileTypes(props.datasource); + const label = props.datasource.backendType === 'phlare' ? 'Profile type' : 'Application'; + return ( + + + Select {props.datasource.backendType === 'phlare' ? 'profile type' : 'application'} for which to retrieve + available labels + + } + > + {profileTypes ? ( + + ) : ( + + )} + + + ); +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts new file mode 100644 index 00000000000..5dcc47ab9e9 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.test.ts @@ -0,0 +1,73 @@ +import { lastValueFrom } from 'rxjs'; + +import { CoreApp, DataQueryRequest, getDefaultTimeRange } from '@grafana/data'; + +import { DataAPI, VariableSupport } from './VariableSupport'; +import { ProfileTypeMessage, VariableQuery } from './types'; + +describe('VariableSupport', () => { + it('should query profiles', async function () { + const mock = getDataApiMock(); + const vs = new VariableSupport(mock); + const resp = await lastValueFrom(vs.query(getDefaultRequest())); + expect(resp.data).toEqual([ + { text: 'profile type 1', value: 'profile:type:1' }, + { text: 'profile type 2', value: 'profile:type:2' }, + { text: 'profile type 3', value: 'profile:type:3' }, + ]); + }); + + it('should query labels', async function () { + const mock = getDataApiMock(); + const vs = new VariableSupport(mock); + const resp = await lastValueFrom( + vs.query(getDefaultRequest({ type: 'label', profileTypeId: 'profile:type:3', refId: 'A' })) + ); + expect(resp.data).toEqual([{ text: 'foo' }, { text: 'bar' }, { text: 'baz' }]); + expect(mock.getLabelNames).toBeCalledWith('profile:type:3{}', expect.any(Number), expect.any(Number)); + }); + + it('should query label values', async function () { + const mock = getDataApiMock(); + const vs = new VariableSupport(mock); + const resp = await lastValueFrom( + vs.query(getDefaultRequest({ type: 'labelValue', labelName: 'foo', profileTypeId: 'profile:type:3', refId: 'A' })) + ); + expect(resp.data).toEqual([{ text: 'val1' }, { text: 'val2' }, { text: 'val3' }]); + expect(mock.getLabelValues).toBeCalledWith('profile:type:3{}', 'foo', expect.any(Number), expect.any(Number)); + }); +}); + +function getDefaultRequest( + query: VariableQuery = { type: 'profileType', refId: 'A' } +): DataQueryRequest { + return { + targets: [query], + interval: '1s', + intervalMs: 1000, + range: getDefaultTimeRange(), + scopedVars: {}, + timezone: 'utc', + app: CoreApp.Unknown, + requestId: '1', + startTime: 0, + }; +} + +function getDataApiMock(): DataAPI { + const profiles: ProfileTypeMessage[] = [ + { id: 'profile:type:1', label: 'profile type 1' }, + { id: 'profile:type:2', label: 'profile type 2' }, + { id: 'profile:type:3', label: 'profile type 3' }, + ]; + const getProfileTypes = jest.fn().mockResolvedValueOnce(profiles); + + const getLabelValues = jest.fn().mockResolvedValueOnce(['val1', 'val2', 'val3']); + const getLabelNames = jest.fn().mockResolvedValueOnce(['foo', 'bar', 'baz']); + + return { + getProfileTypes, + getLabelNames, + getLabelValues, + }; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts new file mode 100644 index 00000000000..bf71c4d1dc6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/VariableSupport.ts @@ -0,0 +1,75 @@ +import { from, map, Observable, of } from 'rxjs'; + +import { CustomVariableSupport, DataQueryRequest, DataQueryResponse, MetricFindValue } from '@grafana/data'; + +import { getTimeSrv, TimeSrv } from '../../../features/dashboard/services/TimeSrv'; + +import { VariableQueryEditor } from './VariableQueryEditor'; +import { PhlareDataSource } from './datasource'; +import { ProfileTypeMessage, VariableQuery } from './types'; + +export interface DataAPI { + getProfileTypes(): Promise; + getLabelNames(query: string, start: number, end: number): Promise; + getLabelValues(query: string, label: string, start: number, end: number): Promise; +} + +export class VariableSupport extends CustomVariableSupport { + constructor( + private readonly dataAPI: DataAPI, + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(); + // This is needed because code in queryRunners.ts passes this method without binding it. + this.query = this.query.bind(this); + } + + editor = VariableQueryEditor; + + query(request: DataQueryRequest): Observable { + if (request.targets[0].type === 'profileType') { + return from(this.dataAPI.getProfileTypes()).pipe( + map((values) => { + return { data: values.map((v) => ({ text: v.label, value: v.id })) }; + }) + ); + } + + if (request.targets[0].type === 'label') { + if (!request.targets[0].profileTypeId) { + return of({ data: [] }); + } + return from( + this.dataAPI.getLabelNames( + request.targets[0].profileTypeId + '{}', + this.timeSrv.timeRange().from.valueOf(), + this.timeSrv.timeRange().to.valueOf() + ) + ).pipe( + map((values) => { + return { data: values.map((v) => ({ text: v })) }; + }) + ); + } + + if (request.targets[0].type === 'labelValue') { + if (!request.targets[0].labelName || !request.targets[0].profileTypeId) { + return of({ data: [] }); + } + return from( + this.dataAPI.getLabelValues( + request.targets[0].profileTypeId + '{}', + request.targets[0].labelName, + this.timeSrv.timeRange().from.valueOf(), + this.timeSrv.timeRange().to.valueOf() + ) + ).pipe( + map((values) => { + return { data: values.map((v) => ({ text: v })) }; + }) + ); + } + + return of({ data: [] }); + } +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index 3d722b5b343..e94ad18a084 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -50,32 +50,27 @@ describe('Phlare data source', () => { }); describe('applyTemplateVariables', () => { - const interpolationVar = '$interpolationVar'; - const interpolationText = 'interpolationText'; - const noInterpolation = 'noInterpolation'; + const templateSrv = new TemplateSrv(); + templateSrv.replace = jest.fn((query: string): string => { + return query.replace(/\$var/g, 'interpolated'); + }); it('should not update labelSelector if there are no template variables', () => { - const templateSrv = new TemplateSrv(); - templateSrv.replace = jest.fn((query: string): string => { - return query.replace(/\$interpolationVar/g, interpolationText); - }); ds = new PhlareDataSource(defaultSettings, templateSrv); - const query = ds.applyTemplateVariables(defaultQuery(`{${noInterpolation}}`), {}); - expect(templateSrv.replace).toBeCalledTimes(1); - expect(query.labelSelector).toBe(`{${noInterpolation}}`); + const query = ds.applyTemplateVariables(defaultQuery({ labelSelector: `no var`, profileTypeId: 'no var' }), {}); + expect(query).toMatchObject({ + labelSelector: `no var`, + profileTypeId: 'no var', + }); }); it('should update labelSelector if there are template variables', () => { - const templateSrv = new TemplateSrv(); - templateSrv.replace = jest.fn((query: string): string => { - return query.replace(/\$interpolationVar/g, interpolationText); - }); ds = new PhlareDataSource(defaultSettings, templateSrv); - const query = ds.applyTemplateVariables(defaultQuery(`{${interpolationVar}="${interpolationVar}"}`), { - interpolationVar: { text: interpolationText, value: interpolationText }, - }); - expect(templateSrv.replace).toBeCalledTimes(1); - expect(query.labelSelector).toBe(`{${interpolationText}="${interpolationText}"}`); + const query = ds.applyTemplateVariables( + defaultQuery({ labelSelector: `{$var="$var"}`, profileTypeId: '$var' }), + {} + ); + expect(query).toMatchObject({ labelSelector: `{interpolated="interpolated"}`, profileTypeId: 'interpolated' }); }); }); }); @@ -116,13 +111,14 @@ describe('normalizeQuery', () => { }); }); -const defaultQuery = (query: string) => { +const defaultQuery = (query: Partial): Query => { return { refId: 'x', groupBy: [], - labelSelector: query, + labelSelector: '', profileTypeId: '', queryType: defaultPhlareQueryType, + ...query, }; }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts index 935ccee324e..ca87598b460 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts @@ -13,6 +13,7 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import { extractLabelMatchers, toPromLikeExpr } from '../prometheus/language_utils'; +import { VariableSupport } from './VariableSupport'; import { defaultGrafanaPyroscope, defaultPhlareQueryType } from './dataquery.gen'; import { PhlareDataSourceOptions, Query, ProfileTypeMessage, BackendType } from './types'; @@ -25,6 +26,7 @@ export class PhlareDataSource extends DataSourceWithBackend): Observable { @@ -50,15 +52,20 @@ export class PhlareDataSource extends DataSourceWithBackend { - return await super.getResource('profileTypes'); + return await this.getResource('profileTypes'); } async getLabelNames(query: string, start: number, end: number): Promise { - return await super.getResource('labelNames', { query, start, end }); + return await this.getResource('labelNames', { query: this.templateSrv.replace(query), start, end }); } async getLabelValues(query: string, label: string, start: number, end: number): Promise { - return await super.getResource('labelValues', { label, query, start, end }); + return await this.getResource('labelValues', { + label: this.templateSrv.replace(label), + query: this.templateSrv.replace(query), + start, + end, + }); } // We need the URL here because it may not be saved on the backend yet when used from config page. @@ -70,6 +77,7 @@ export class PhlareDataSource extends DataSourceWithBackend