Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions grafana-datasource-plugin/src/components/QueryEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { DataSource } from '../datasource';
import { ChannelRef, DEFAULT_QUERY, MyDataSourceOptions, MyQuery, withDefaults } from '../types';
import { QueryEditorProps } from '@grafana/data';

jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => ({
replace: (value: string) => value,
getVariables: () => [],
containsTemplate: () => false,
}),
}));

beforeAll(() => {
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
Expand Down
16 changes: 12 additions & 4 deletions grafana-datasource-plugin/src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import React, { useState } from 'react';
import { css } from '@emotion/css';
import { ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { dateTime, QueryEditorProps, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { DataSource } from '../datasource';
import { MyDataSourceOptions, MyQuery, withDefaults } from '../types';
import { MyDataSourceOptions, MyQuery, ResolvedQuery, withDefaults } from '../types';
import { BuilderEditor } from './BuilderEditor';
import { SqlEditor } from './SqlEditor';
import { buildQuery } from '../query';
import { buildQuery, resolveChannels } from '../query';

type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;

Expand All @@ -21,7 +22,7 @@ export function QueryEditor({ query, onChange, onRunQuery, datasource, range }:
const [builderQueryType, setBuilderQueryType] = useState<string>(query.queryType ?? 'telemetry');
const [generatedSql, setGeneratedSql] = useState<string | undefined>(undefined);

const onEditorModeChange = (mode: string) => {
const onEditorModeChange = async (mode: string) => {
if (mode === 'builder' && editorMode === 'code') {
const userEdited = query.rawSql?.trim() && query.rawSql !== generatedSql;
if (userEdited) {
Expand All @@ -40,9 +41,16 @@ export function QueryEditor({ query, onChange, onRunQuery, datasource, range }:
if (mode === 'code') {
try {
const filled = withDefaults(query);
const templateSrv = getTemplateSrv();
const needsChannels = (filled.channels ?? []).some((c) => c.raw !== undefined);
const known = needsChannels ? await datasource.getChannels().catch(() => []) : [];
const resolved: ResolvedQuery = {
...filled,
channels: resolveChannels(filled.channels ?? [], (value) => templateSrv.replace(value), known),
};
const from = range?.from ?? dateTime();
const to = range?.to ?? dateTime();
const sql = buildQuery(filled, { range: { from, to, raw: { from, to } } } as any);
const sql = buildQuery(resolved, { range: { from, to, raw: { from, to } } } as any);
setGeneratedSql(sql);
onChange({ ...query, rawSql: sql, queryType: 'raw' });
} catch (e) {
Expand Down
116 changes: 105 additions & 11 deletions grafana-datasource-plugin/src/components/TelemetryFields.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Combobox, ComboboxOption, InlineField, MultiCombobox } from '@grafana/ui';
import { getTemplateSrv } from '@grafana/runtime';
import { DataSource } from '../datasource';
import { Aggregation, ChannelRef, KeyRef, MyQuery } from '../types';
import { Aggregation, ChannelQuery, ChannelRef, KeyRef, MyQuery } from '../types';

interface TelemetryFieldsProps {
query: MyQuery;
Expand Down Expand Up @@ -65,10 +66,6 @@ function channelToKey(ch: ChannelRef): string {
return JSON.stringify(ch);
}

function keyToChannel(key: string): ChannelRef {
return JSON.parse(key) as ChannelRef;
}

function toChannelOptions(entries: ChannelRef[]): Array<ComboboxOption<string>> {
return entries.map((e) => ({
label: `${e.component}.${e.name}`,
Expand All @@ -77,8 +74,36 @@ function toChannelOptions(entries: ChannelRef[]): Array<ComboboxOption<string>>
}));
}

function channelValues(channels: ChannelRef[]): string[] {
return channels.map(channelToKey);
function channelLabel(ch: ChannelQuery): string {
if (ch.raw !== undefined) {
return ch.raw;
}
// Avoid rendering a stray trailing dot when a channel has no name.
return ch.name ? `${ch.component}.${ch.name}` : ch.component;
}

function channelValue(ch: ChannelQuery): string {
return ch.raw !== undefined ? ch.raw : channelToKey(ch);
}

function channelValuesOrOptions(channels: ChannelQuery[]): Array<ComboboxOption<string>> {
return channels.map(ch => ({
label: channelLabel(ch),
value: channelValue(ch),
}));
}

function referencedVariables(input: string): string[] {
return (input.match(/\$\{?\w+\}?/g) ?? []).map((tok) => tok.replace(/[${}]/g, ''));
}

function isVariableReference(input: string): boolean {
const refs = referencedVariables(input);
if (refs.length === 0) {
return false;
}
const defined = new Set(getTemplateSrv().getVariables().map((v) => v.name));
return refs.every((name) => defined.has(name));
}

export function TelemetryFields({ query, onChange, onRunQuery, datasource }: TelemetryFieldsProps) {
Expand All @@ -90,10 +115,69 @@ export function TelemetryFields({ query, onChange, onRunQuery, datasource }: Tel
const [sourceLoading, setSourceLoading] = useState(false);
const [keyLoading, setKeyLoading] = useState(false);

// --- Helpers ---

const getChannelOptionsWithVariables = async (inputValue: string): Promise<Array<ComboboxOption<string>>> => {
const options: Array<ComboboxOption<string>> = [];

if (isVariableReference(inputValue)) {
options.push({ label: inputValue, value: inputValue, description: 'Use template variable' });
}

// Autocomplete hints for the template variable currently being typed.
if (inputValue.includes('$')) {
const partialMatch = inputValue.match(/\$\w*$/);
if (partialMatch) {
const partial = partialMatch[0];
const prefix = inputValue.slice(0, partialMatch.index);
const variableNames = getTemplateSrv().getVariables().map((v) => `$${v.name}`);

const hints = variableNames
.filter((name) => name.toLowerCase().startsWith(partial.toLowerCase()))
.map((name) => `${prefix}${name}`)
.filter((suggestion) => suggestion !== inputValue)
.map((suggestion) => ({ label: suggestion, value: suggestion, infoOption: true, icon: 'code-branch' as const }));
options.push(...hints);
}
return options;
}

const matches = channelOptions.filter(opt =>
opt.label?.toLowerCase().includes(inputValue.toLowerCase())
);
options.push(...matches);
return options;
};

// --- Handlers ---

const onChannelChange = (options: Array<ComboboxOption<string>>) => {
const channels = options.map(({ value }) => keyToChannel(value));
const channels = options
.map(({ value, label }): ChannelQuery | null => {
const valueStr = typeof value === 'string' ? value : String(value ?? '');

// Known-channel options encode a { component, name } object as JSON.
if (valueStr.startsWith('{')) {
try {
const parsed = JSON.parse(valueStr) as ChannelRef;
if (typeof parsed.component === 'string' && typeof parsed.name === 'string') {
return { component: parsed.component, name: parsed.name };
}
} catch {
// Treat as raw text
}
}

// Custom template variable reference
const raw = valueStr || label || '';
if (!isVariableReference(raw)) {
return null;
}

return { raw };
})
.filter((ch): ch is ChannelQuery => ch !== null);

const updated: MyQuery = { ...query, channels, keys: [], sources: [] };
onChange(updated);
if (channels.length) {
Expand Down Expand Up @@ -157,6 +241,16 @@ export function TelemetryFields({ query, onChange, onRunQuery, datasource }: Tel
loadSources();
}, [datasource]);

// Update keys when vars change
const templateSrv = getTemplateSrv();
const resolvedChannelsKey = JSON.stringify(
(query.channels ?? []).map((ch) =>
ch.raw !== undefined
? templateSrv.replace(ch.raw)
: `${templateSrv.replace(ch.component)}\u0000${templateSrv.replace(ch.name)}`
)
);

useEffect(() => {
if (!query.channels || !query.channels.length) {
setTimeout(() => setKeysByChannel({}), 0);
Expand All @@ -171,7 +265,7 @@ export function TelemetryFields({ query, onChange, onRunQuery, datasource }: Tel
.finally(() => setKeyLoading(false));
}
loadKeys();
}, [datasource, query.channels]);
}, [datasource, query.channels, resolvedChannelsKey]);

useEffect(() => {
const currentKeys = query.keys ?? [];
Expand Down Expand Up @@ -200,8 +294,8 @@ export function TelemetryFields({ query, onChange, onRunQuery, datasource }: Tel
<MultiCombobox
id="query-editor-channel"
data-testid="query-editor-channel"
options={channelOptions}
value={channelValues(query.channels ?? [])}
options={getChannelOptionsWithVariables}
value={channelValuesOrOptions(query.channels ?? [])}
onChange={onChannelChange}
loading={channelLoading}
placeholder="Select channel"
Expand Down
71 changes: 46 additions & 25 deletions grafana-datasource-plugin/src/datasource.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import { DataQueryRequest, DataSourceInstanceSettings, CoreApp, ScopedVars } from '@grafana/data';
import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
import { map } from 'rxjs/operators';
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, ChannelRef, KeyRef, withDefaults } from './types';
import { buildQuery } from 'query';
import { from } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, ChannelQuery, ChannelRef, KeyRef, ResolvedQuery, withDefaults } from './types';
import { buildQuery, resolveChannels } from 'query';

export class DataSource extends DataSourceWithBackend<MyQuery, MyDataSourceOptions> {
private knownChannels?: Promise<ChannelRef[]>;

constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
super(instanceSettings);
}

// Fetch (and cache) the known channel list.
private getKnownChannels(): Promise<ChannelRef[]> {
if (!this.knownChannels) {
this.knownChannels = this.getChannels().catch(() => []);
}
return this.knownChannels;
}

query(request: DataQueryRequest<MyQuery>) {
// Build raw SQL for each target if not already provided
request.targets.forEach((target) => {
const filled = withDefaults(target);
Object.assign(target, filled);
if (!target.rawSql) {
target.rawSql = buildQuery(target, request);
}
});

return super.query(request).pipe(
const needsChannels = request.targets.some((t) =>
(t.channels ?? []).some((c) => c.raw !== undefined)
);
const known$ = from(needsChannels ? this.getKnownChannels() : Promise.resolve<ChannelRef[]>([]));

return known$.pipe(
switchMap((known) => {
request.targets.forEach((target) => {
const resolved = this.resolveTargetVariables(withDefaults(target), request.scopedVars, known);
Object.assign(target, resolved);

if (!target.rawSql) {
target.rawSql = buildQuery(resolved, request);
}
});

return super.query(request);
}),
map((response) => {
for (const result of response.data) {
const query = request.targets.find((t) => t.refId === result.refId);
Expand All @@ -36,19 +55,17 @@ export class DataSource extends DataSourceWithBackend<MyQuery, MyDataSourceOptio
return DEFAULT_QUERY;
}

applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars) {
private resolveTargetVariables(query: MyQuery, scopedVars: ScopedVars, known: ChannelRef[] = []): ResolvedQuery {
const templateSrv = getTemplateSrv();
const replace = (value: string) => templateSrv.replace(value, scopedVars);
return {
...query,
channels: query.channels?.map(ch => ({
component: templateSrv.replace(ch.component, scopedVars),
name: templateSrv.replace(ch.name, scopedVars),
})) ?? [],
sources: query.sources?.map(s => templateSrv.replace(s, scopedVars)) ?? [],
channels: resolveChannels(query.channels ?? [], replace, known),
sources: query.sources?.map(replace) ?? [],
keys: query.keys?.map(k => ({
component: templateSrv.replace(k.component, scopedVars),
channel: templateSrv.replace(k.channel, scopedVars),
key: templateSrv.replace(k.key, scopedVars),
component: replace(k.component),
channel: replace(k.channel),
key: replace(k.key),
})) ?? [],
};
}
Expand All @@ -74,13 +91,17 @@ export class DataSource extends DataSourceWithBackend<MyQuery, MyDataSourceOptio
return this.getResource('telemetry/sources');
}

async getKeys(channels: ChannelRef[]): Promise<KeyRef[]> {
const components = [...new Set(channels.map(ch => ch.component))];
const names = channels.map(ch => ch.name);
async getKeys(channels: ChannelQuery[]): Promise<KeyRef[]> {
const templateSrv = getTemplateSrv();
const known = channels.some((c) => c.raw !== undefined) ? await this.getKnownChannels() : [];
const expanded = resolveChannels(channels, (value) => templateSrv.replace(value), known);
const components = [...new Set(expanded.map((ch) => ch.component))];
const names = expanded.map((ch) => ch.name);
return this.getResource('telemetry/keys', { components, channels: names });
}

async getEventSources(): Promise<string[]> {
return this.getResource('events/sources');
}

}
Loading
Loading