mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Pyroscope: Template variable support (#73572)
This commit is contained in:
parent
66f60bc410
commit
a6ff50300e
@ -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 () => {
|
||||
|
@ -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 || '',
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
@ -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 () => {
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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: [] });
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user