Pyroscope: Template variable support (#73572)

This commit is contained in:
Andrej Ocenas 2023-08-30 10:48:39 +02:00 committed by GitHub
parent 66f60bc410
commit a6ff50300e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 577 additions and 125 deletions

View File

@ -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 () => {

View File

@ -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<CascaderProps, CascaderState> {
constructor(props: CascaderProps) {
@ -94,7 +96,7 @@ export class Cascader extends PureComponent<CascaderProps, CascaderState> {
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<CascaderProps, CascaderState> {
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 || '',

View File

@ -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);

View File

@ -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 (
<Cascader
separator={'-'}
displayAllSelectedLevels={true}
initialValue={props.initialProfileTypeId}
allowCustomValue={true}
onSelect={props.onChange}
options={cascaderOptions}
changeOnSelect={false}
/>
);
}
// Turn profileTypes into cascader options
function useCascaderOptions(profileTypes?: ProfileTypeMessage[]): CascaderOption[] {
return useMemo(() => {
if (!profileTypes) {
return [];
}
let mainTypes = new Map<string, CascaderOption>();
// 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<ProfileTypeMessage[]>();
useEffect(() => {
(async () => {
const profileTypes = await datasource.getProfileTypes();
setProfileTypes(profileTypes);
})();
}, [datasource]);
return profileTypes;
}

View File

@ -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 () => {

View File

@ -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<PhlareDataSource, Query, PhlareDataSourceOptions>;
@ -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 (
<EditorRows>
<EditorRow stackProps={{ wrap: false, gap: 1 }}>
<ButtonCascader onChange={onProfileTypeChange} options={cascaderOptions} buttonProps={{ variant: 'secondary' }}>
{selectedProfileName}
</ButtonCascader>
{/*
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 ? (
<ProfileTypesCascader
profileTypes={profileTypes}
initialProfileTypeId={query.profileTypeId}
onChange={(val) => {
onChange({ ...query, profileTypeId: val });
}}
/>
) : (
<LoadingPlaceholder text={'Loading'} />
)}
<LabelsEditor
value={query.labelSelector}
onChange={onLabelSelectorChange}
@ -52,15 +64,18 @@ export function QueryEditor(props: Props) {
function useNormalizeQuery(
query: Query,
profileTypes: ProfileTypeMessage[],
profileTypes: ProfileTypeMessage[] | undefined,
onChange: (value: Query) => 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<string, CascaderOption>();
// 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<ProfileTypeMessage[]>([]);
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]);
}

View File

@ -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(
<VariableQueryEditor
datasource={getMockDatasource()}
query={{
refId: 'A',
type: 'profileType',
}}
onRunQuery={() => {}}
onChange={() => {}}
/>
);
expect(screen.getByLabelText(/Query type/)).toBeInTheDocument();
});
it('renders correctly with type labels', async () => {
render(
<VariableQueryEditor
datasource={getMockDatasource()}
query={{
refId: 'A',
type: 'label',
profileTypeId: 'profile:type:1',
}}
onRunQuery={() => {}}
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(
<VariableQueryEditor
datasource={getMockDatasource()}
query={{
refId: 'A',
type: 'labelValue',
labelName: 'foo',
profileTypeId: 'cpu',
}}
onRunQuery={() => {}}
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<PhlareDataSourceOptions>,
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;
}

View File

@ -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<PhlareDataSource, Query, {}, VariableQuery>) {
return (
<>
<InlineFieldRow>
<InlineField
label="Query type"
labelWidth={20}
tooltip={
<div>The Prometheus data source plugin provides the following query types for template variables</div>
}
>
<Select
placeholder="Select query type"
aria-label="Query type"
width={25}
options={[
{ label: 'Profile type', value: 'profileType' as const },
{ label: 'Label', value: 'label' as const },
{ label: 'Label value', value: 'labelValue' as const },
]}
onChange={(value) => {
if (value.value! === 'profileType') {
props.onChange({
...props.query,
type: value.value!,
});
}
if (value.value! === 'label') {
props.onChange({
...props.query,
type: value.value!,
profileTypeId: '',
});
}
if (value.value! === 'labelValue') {
props.onChange({
...props.query,
type: value.value!,
profileTypeId: '',
labelName: '',
});
}
}}
value={props.query.type}
/>
</InlineField>
</InlineFieldRow>
{(props.query.type === 'labelValue' || props.query.type === 'label') && (
<ProfileTypeRow
datasource={props.datasource}
initialValue={props.query.profileTypeId}
onChange={(val) => {
// To make TS happy
if (props.query.type === 'label' || props.query.type === 'labelValue') {
props.onChange({ ...props.query, profileTypeId: val });
}
}}
/>
)}
{props.query.type === 'labelValue' && (
<LabelRow
value={props.query.labelName}
datasource={props.datasource}
profileTypeId={props.query.profileTypeId}
onChange={(val) => {
if (props.query.type === 'labelValue') {
props.onChange({ ...props.query, labelName: val });
}
}}
from={props.range?.from.valueOf() || Date.now().valueOf() - 1000 * 60 * 60 * 24}
to={props.range?.to.valueOf() || Date.now().valueOf()}
/>
)}
</>
);
}
function LabelRow(props: {
datasource: PhlareDataSource;
value?: string;
profileTypeId?: string;
from: number;
to: number;
onChange: (val: string) => void;
}) {
const [labels, setLabels] = useState<string[]>();
useEffect(() => {
(async () => {
setLabels(await props.datasource.getLabelNames((props.profileTypeId || '') + '{}', props.from, props.to));
})();
}, [props.datasource, props.profileTypeId, props.to, props.from]);
const options = labels ? labels.map<SelectableValue>((v) => ({ label: v, value: v })) : [];
if (labels && props.value && !labels.find((v) => v === props.value)) {
options.push({ value: props.value, label: props.value });
}
return (
<InlineFieldRow>
<InlineField
label={'Label'}
labelWidth={20}
tooltip={<div>Select label for which to retrieve available values</div>}
>
<Select
allowCustomValue={true}
placeholder="Select label"
aria-label="Select label"
width={25}
options={options}
onChange={(option) => props.onChange(option.value)}
value={props.value}
/>
</InlineField>
</InlineFieldRow>
);
}
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 (
<InlineFieldRow>
<InlineField
label={label}
aria-label={label}
labelWidth={20}
tooltip={
<div>
Select {props.datasource.backendType === 'phlare' ? 'profile type' : 'application'} for which to retrieve
available labels
</div>
}
>
{profileTypes ? (
<ProfileTypesCascader
onChange={props.onChange}
profileTypes={profileTypes}
initialProfileTypeId={props.initialValue}
/>
) : (
<LoadingPlaceholder text={'Loading'} />
)}
</InlineField>
</InlineFieldRow>
);
}

View File

@ -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<VariableQuery> {
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,
};
}

View File

@ -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<ProfileTypeMessage[]>;
getLabelNames(query: string, start: number, end: number): Promise<string[]>;
getLabelValues(query: string, label: string, start: number, end: number): Promise<string[]>;
}
export class VariableSupport extends CustomVariableSupport<PhlareDataSource> {
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<VariableQuery>): Observable<DataQueryResponse> {
if (request.targets[0].type === 'profileType') {
return from(this.dataAPI.getProfileTypes()).pipe(
map((values) => {
return { data: values.map<MetricFindValue>((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: [] });
}
}

View File

@ -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>): Query => {
return {
refId: 'x',
groupBy: [],
labelSelector: query,
labelSelector: '',
profileTypeId: '',
queryType: defaultPhlareQueryType,
...query,
};
};

View File

@ -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<Query, PhlareDataSou
) {
super(instanceSettings);
this.backendType = instanceSettings.jsonData.backendType ?? 'phlare';
this.variables = new VariableSupport(this);
}
query(request: DataQueryRequest<Query>): Observable<DataQueryResponse> {
@ -50,15 +52,20 @@ export class PhlareDataSource extends DataSourceWithBackend<Query, PhlareDataSou
}
async getProfileTypes(): Promise<ProfileTypeMessage[]> {
return await super.getResource('profileTypes');
return await this.getResource('profileTypes');
}
async getLabelNames(query: string, start: number, end: number): Promise<string[]> {
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<string[]> {
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<Query, PhlareDataSou
return {
...query,
labelSelector: this.templateSrv.replace(query.labelSelector ?? '', scopedVars),
profileTypeId: this.templateSrv.replace(query.profileTypeId ?? '', scopedVars),
};
}

View File

@ -20,3 +20,23 @@ export interface PhlareDataSourceOptions extends DataSourceJsonData {
}
export type BackendType = 'phlare' | 'pyroscope';
export type ProfileTypeQuery = {
type: 'profileType';
refId: string;
};
export type LabelQuery = {
type: 'label';
profileTypeId?: string;
refId: string;
};
export type LabelValueQuery = {
type: 'labelValue';
profileTypeId?: string;
labelName?: string;
refId: string;
};
export type VariableQuery = ProfileTypeQuery | LabelQuery | LabelValueQuery;