From a7bfb268f9e51e997725b8ae593e243569057f19 Mon Sep 17 00:00:00 2001 From: Tigran Vardanyan Date: Thu, 4 Apr 2024 18:20:45 +0400 Subject: [PATCH 1/2] add multiple tag support add variable support --- src/core/utils.test.ts | 49 +++- src/core/utils.ts | 38 +++ src/datasources/tag/TagDataSource.test.ts | 56 +++-- src/datasources/tag/TagDataSource.ts | 236 ++++++++++++++---- .../__snapshots__/TagDataSource.test.ts.snap | 132 ++++++++-- .../tag/components/TagQueryEditor.tsx | 22 +- .../TagVariableQueryEditor.test.tsx | 64 +++++ .../tag/components/TagVariableQueryEditor.tsx | 54 ++++ src/datasources/tag/module.ts | 4 +- src/datasources/tag/types.ts | 40 ++- 10 files changed, 586 insertions(+), 109 deletions(-) create mode 100644 src/datasources/tag/components/TagVariableQueryEditor.test.tsx create mode 100644 src/datasources/tag/components/TagVariableQueryEditor.tsx diff --git a/src/core/utils.test.ts b/src/core/utils.test.ts index bc343bf3..7354b830 100644 --- a/src/core/utils.test.ts +++ b/src/core/utils.test.ts @@ -1,15 +1,44 @@ -import { enumToOptions } from "./utils"; +import { enumToOptions, expandMultipleValueVariableAfterReplace } from "./utils"; test('enumToOptions', () => { - enum fakeStringEnum { - Label1 = 'Value1', - Label2 = 'Value2' - }; + enum fakeStringEnum { + Label1 = 'Value1', + Label2 = 'Value2' + } - const result = enumToOptions(fakeStringEnum); + const result = enumToOptions(fakeStringEnum); - expect(result).toEqual([ - { label: 'Label1', value: 'Value1' }, - { label: 'Label2', value: 'Value2' } - ]); + expect(result).toEqual([ + { label: 'Label1', value: 'Value1' }, + { label: 'Label2', value: 'Value2' } + ]); }); + +describe('expandMultipleValueVariableAfterReplace', () => { + test('basicString', () => { + expect(expandMultipleValueVariableAfterReplace('a{{b,c,d}}e')).toEqual(['abe', 'ace', 'ade']); + }) + + test('doublePatternString', () => { + expect(expandMultipleValueVariableAfterReplace('{{a,b}}-{{c,d}}')).toEqual(['a-c', 'a-d', 'b-c', 'b-d']); + }) + test('doublePatternWithoutSeparatorString', () => { + expect(expandMultipleValueVariableAfterReplace('{{a,b}}{{c,d}}')).toEqual(['ac', 'ad', 'bc', 'bd']); + }) + test('plainString', () => { + expect(expandMultipleValueVariableAfterReplace('plainString')).toEqual(['plainString']); + }) + test('ignoreEmptyElementsInBracesString', () => { + expect(expandMultipleValueVariableAfterReplace('a{{b,c,,d,e,,f,}}g')).toEqual(['abg', 'acg', 'adg', 'aeg', 'afg']); + }) + test('nestedString', () => { + expect(() => { + expandMultipleValueVariableAfterReplace('a{{b,{{c}}},e') + }).toThrow('Nested braces are not allowed'); + }) + test('unmatchedBracesString', () => { + expect(() => { + expandMultipleValueVariableAfterReplace('error{{test') + }).toThrow('Unmatched braces'); + }) +}) diff --git a/src/core/utils.ts b/src/core/utils.ts index 26aefcfa..5a2ce455 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -82,3 +82,41 @@ export function replaceVariables(values: string[], templateSrv: TemplateSrv) { // Dedupe and flatten return [...new Set(replaced.flat())]; } + +export const expandMultipleValueVariableAfterReplace = (input: string): string[] => { + // Helper function to recursively generate combinations + const generateCombinations = (text: string, start = 0): string[] => { + // Find the next pattern in the string + const open = text.indexOf('{{', start); + if (open === -1) { + // No more patterns, return the current string + return [text]; + } + const close = text.indexOf('}}', open); + if (close === -1) { + throw new Error('Unmatched braces'); + } + + // Check for nested braces + const nestedOpen = text.indexOf('{{', open + 1); + if (nestedOpen !== -1 && nestedOpen < close) { + throw new Error('Nested braces are not allowed'); + } + + // Extract the options within the braces and generate combinations + const options = text.substring(open + 2, close).split(',').filter((option) => option !== ''); + const prefix = text.substring(0, open); + const suffix = text.substring(close + 2); + + let combinations: string[] = []; + for (const option of options) { + // For each option, replace the pattern with the option and generate further combinations + const newCombinations = generateCombinations(prefix + option + suffix); + combinations = combinations.concat(newCombinations); + } + + return combinations; + }; + + return generateCombinations(input); +}; diff --git a/src/datasources/tag/TagDataSource.test.ts b/src/datasources/tag/TagDataSource.test.ts index 45860be1..90e3ded0 100644 --- a/src/datasources/tag/TagDataSource.test.ts +++ b/src/datasources/tag/TagDataSource.test.ts @@ -12,7 +12,7 @@ import { setupDataSource, } from 'test/fixtures'; import { TagDataSource } from './TagDataSource'; -import { TagQuery, TagQueryType, TagWithValue } from './types'; +import { TagQuery, TagQueryType, TagWithValue, TagDataType } from './types'; let ds: TagDataSource, backendSrv: MockProxy, templateSrv: MockProxy; @@ -46,11 +46,9 @@ describe('testDatasource', () => { describe('queries', () => { test('tag current value', async () => { backendSrv.fetch - .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: 'path = "my.tag"' } })) + .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: '(path = "my.tag")' } })) .mockReturnValue(createQueryTagsResponse()); - const result = await ds.query(buildQuery({ path: 'my.tag' })); - expect(result.data).toMatchSnapshot(); }); @@ -88,10 +86,10 @@ describe('queries', () => { test('multiple targets - skips invalid queries', async () => { backendSrv.fetch - .calledWith(requestMatching({ data: { filter: 'path = "my.tag1"' } })) + .calledWith(requestMatching({ data: { filter: '(path = \"my.tag1\")' } })) .mockReturnValue(createQueryTagsResponse({ path: 'my.tag1' })); backendSrv.fetch - .calledWith(requestMatching({ data: { filter: 'path = "my.tag2"' } })) + .calledWith(requestMatching({ data: { filter: '(path = \"my.tag2\")' } })) .mockReturnValue(createQueryTagsResponse({ path: 'my.tag2' }, { value: { value: '41.3' } })); const result = await ds.query(buildQuery({ path: 'my.tag1' }, { path: '' }, { path: 'my.tag2' })); @@ -101,12 +99,21 @@ describe('queries', () => { test('current value for all data types', async () => { backendSrv.fetch - .mockReturnValueOnce(createQueryTagsResponse({ type: 'INT', path: 'tag1' }, { value: { value: '3' } })) - .mockReturnValueOnce(createQueryTagsResponse({ type: 'DOUBLE', path: 'tag2' }, { value: { value: '3.3' } })) - .mockReturnValueOnce(createQueryTagsResponse({ type: 'STRING', path: 'tag3' }, { value: { value: 'foo' } })) - .mockReturnValueOnce(createQueryTagsResponse({ type: 'BOOLEAN', path: 'tag4' }, { value: { value: 'True' } })) + .mockReturnValueOnce(createQueryTagsResponse({ type: TagDataType.INT, path: 'tag1' }, { value: { value: '3' } })) + .mockReturnValueOnce(createQueryTagsResponse({ + type: TagDataType.DOUBLE, + path: 'tag2' + }, { value: { value: '3.3' } })) + .mockReturnValueOnce(createQueryTagsResponse({ + type: TagDataType.STRING, + path: 'tag3' + }, { value: { value: 'foo' } })) + .mockReturnValueOnce(createQueryTagsResponse({ + type: TagDataType.BOOLEAN, + path: 'tag4' + }, { value: { value: 'True' } })) .mockReturnValueOnce( - createQueryTagsResponse({ type: 'U_INT64', path: 'tag5' }, { value: { value: '2147483648' } }) + createQueryTagsResponse({ type: TagDataType.U_INT64, path: 'tag5' }, { value: { value: '2147483648' } }) ); const result = await ds.query( @@ -119,14 +126,14 @@ describe('queries', () => { test('throw when no tags matched', async () => { backendSrv.fetch.mockReturnValue(createFetchResponse({ tagsWithValues: [] })); - await expect(ds.query(buildQuery({ path: 'my.tag' }))).rejects.toThrow("No tags matched the path 'my.tag'"); + await expect(ds.query(buildQuery({ path: 'my.tag' }))).rejects.toThrow("No tags matched the path 'path = \"my.tag\"'"); }); test('numeric tag history', async () => { const queryRequest = buildQuery({ type: TagQueryType.History, path: 'my.tag' }); backendSrv.fetch - .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: 'path = "my.tag"' } })) + .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: '(path = "my.tag")' } })) .mockReturnValue(createQueryTagsResponse()); backendSrv.fetch @@ -188,8 +195,10 @@ describe('queries', () => { test('replaces tag path with variable', async () => { templateSrv.replace.calledWith('$my_variable').mockReturnValue('my.tag'); + templateSrv.containsTemplate.calledWith('$my_variable').mockReturnValue(true); + backendSrv.fetch - .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: 'path = "my.tag"' } })) + .calledWith(requestMatching({ url: '/nitag/v2/query-tags-with-values', data: { filter: '(path = "my.tag")' } })) .mockReturnValue(createQueryTagsResponse()); const result = await ds.query(buildQuery({ type: TagQueryType.Current, path: '$my_variable' })); @@ -203,7 +212,7 @@ describe('queries', () => { await ds.query(buildQuery({ type: TagQueryType.History, path: 'my.tag', workspace: '2' })); - expect(backendSrv.fetch.mock.calls[0][0].data).toHaveProperty('filter', 'path = "my.tag" && workspace = "2"'); + expect(backendSrv.fetch.mock.calls[0][0].data).toHaveProperty('filter', '(path = "my.tag") AND workspace = "2"'); expect(backendSrv.fetch.mock.calls[1][0].data).toHaveProperty('workspace', '2'); }); @@ -237,14 +246,29 @@ describe('queries', () => { expect(result.data).toMatchSnapshot(); }); - test('attempts to replace variables in history query', async () => { + test('attempts to replace workspace variables in history query', async () => { const workspaceVariable = '$workspace'; backendSrv.fetch.mockReturnValueOnce(createQueryTagsResponse()); templateSrv.replace.calledWith(workspaceVariable).mockReturnValue('1'); await ds.query(buildQuery({ path: 'my.tag', workspace: workspaceVariable })); + expect(templateSrv.replace).toHaveBeenCalledTimes(1); + expect(templateSrv.replace.mock.calls[0][0]).toBe(workspaceVariable); + }); + + test('attempts to replace tag and workspace variables in history query', async () => { + backendSrv.fetch.mockReturnValueOnce(createQueryTagsResponse()); + const tagVariable = '$my_variable'; + const workspaceVariable = '$workspace'; + templateSrv.replace.calledWith(workspaceVariable).mockReturnValue('1'); + templateSrv.containsTemplate.calledWith(tagVariable).mockReturnValue(true); + templateSrv.replace.calledWith(tagVariable).mockReturnValue('my.tag'); + + await ds.query(buildQuery({ path: tagVariable, workspace: workspaceVariable })); + expect(templateSrv.replace).toHaveBeenCalledTimes(2); + expect(templateSrv.replace.mock.calls[0][0]).toBe(tagVariable); expect(templateSrv.replace.mock.calls[1][0]).toBe(workspaceVariable); }); diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index d6d45d68..e417d739 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -1,17 +1,27 @@ import { DataFrameDTO, DataSourceInstanceSettings, - dateTime, DataQueryRequest, TimeRange, - FieldDTO, + FieldConfig, FieldType, + MetricFindValue, TestDataSourceResponse, } from '@grafana/data'; import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { DataSourceBase } from 'core/DataSourceBase'; -import { throwIfNullish } from 'core/utils'; -import { TagHistoryResponse, TagQuery, TagQueryType, TagsWithValues } from './types'; +import { expandMultipleValueVariableAfterReplace, Throw } from 'core/utils'; +import { + TimeAndTagTypeValues, + TagDataType, + TagHistoryResponse, + TagQuery, + TagQueryType, + TagsWithValues, + TagVariableQuery, + TagWithValue, + TypeAndValues +} from './types'; export class TagDataSource extends DataSourceBase { constructor( @@ -25,7 +35,7 @@ export class TagDataSource extends DataSourceBase { tagUrl = this.instanceSettings.url + '/nitag/v2'; tagHistoryUrl = this.instanceSettings.url + '/nitaghistorian/v2/tags'; - defaultQuery = { + defaultQuery: Omit = { type: TagQueryType.Current, path: '', workspace: '', @@ -33,86 +43,214 @@ export class TagDataSource extends DataSourceBase { }; async runQuery(query: TagQuery, { range, maxDataPoints, scopedVars }: DataQueryRequest): Promise { - const { tag, current } = await this.getLastUpdatedTag( - this.templateSrv.replace(query.path, scopedVars), - this.templateSrv.replace(query.workspace, scopedVars) - ); + let paths: string[] = [query.path]; + if (this.templateSrv.containsTemplate(query.path)) { + // wrap replaced variable in extra curly braces, protecting original {} tags from misinterpretation. + const replaced_path = this.templateSrv.replace(query.path, {}, (a: string) => `{{${a}}}`); + paths = expandMultipleValueVariableAfterReplace(replaced_path); + } + const workspace = this.templateSrv.replace(query.workspace, scopedVars); + + const tagsLastUpdates: TagWithValue[] = await this.getLastUpdatedTag(paths, workspace); - const name = tag.properties?.displayName ?? tag.path; const result: DataFrameDTO = { refId: query.refId, fields: [] }; if (query.type === TagQueryType.Current) { + const allPossibleProps = this.getAllProperties(tagsLastUpdates); result.fields = [ - { name, values: [this.convertTagValue(tag.type ?? tag.datatype, current?.value.value)] }, - { name: 'updated', values: [current?.timestamp], type: FieldType.time, config: { unit: 'dateTimeFromNow' } }, - ]; + { + name: 'name', + values: tagsLastUpdates.map((tag: TagWithValue) => tag.tag.properties?.displayName || tag.tag.path) + }, + { + name: 'current_value', + values: tagsLastUpdates.map((tag: TagWithValue) => this.convertTagValue(tag.tag.type ?? tag.tag.datatype, tag.current?.value.value)), + }, + { + name: 'updated', + values: tagsLastUpdates.map((tag: TagWithValue) => tag.current?.timestamp), + type: FieldType.time, + config: { unit: 'dateTimeFromNow' } + } + ] + if (query.properties) { + allPossibleProps.forEach((prop) => { + result.fields.push( + { + name: prop, + values: tagsLastUpdates.map((tag: TagWithValue) => tag.tag.properties ? tag.tag.properties[prop] : '') + } + ); + }); + } + return result } else { - const history = await this.getTagHistoryValues(tag.path, tag.workspace ?? tag.workspace_id, range, maxDataPoints); - result.fields = [ - { name: 'time', values: history.datetimes }, - { name, values: history.values }, - ]; - } + const tagPropertiesMap: Record | null> = {}; + tagsLastUpdates.forEach((tag: TagWithValue) => { + tagPropertiesMap[tag.tag.path] = tag.tag.properties + }); + const workspaceFromResponse = tagsLastUpdates[0].tag.workspace ?? tagsLastUpdates[0].tag.workspace_id; + const tagHistoryResponse = await this.getTagHistoryWithChunks(Object.keys(tagPropertiesMap), workspaceFromResponse, range, maxDataPoints); + let mergedTagValuesWithType = this.mergeTagsHistoryValues(tagHistoryResponse.results); + result.fields.push({ + name: 'time', 'values': mergedTagValuesWithType.timestamps, type: FieldType.time + }); - if (query.properties) { - result.fields = result.fields.concat(this.getPropertiesAsFields(tag.properties)); - } + for (const path in mergedTagValuesWithType.values) { + const config: FieldConfig = {}; + const tagProps = tagPropertiesMap[path] + if (tagProps?.units) { + config.unit = tagProps.units + } + if (tagProps?.displayName) { + config.displayName = tagProps.displayName + config.displayNameFromDS = tagProps.displayName + } + result.fields.push({ + name: path, + values: mergedTagValuesWithType.values[path].values.map((value) => { + return this.convertTagValue(mergedTagValuesWithType.values[path].type, value) + }), + config + }); + } - return result; + return result + } } - private async getLastUpdatedTag(path: string, workspace: string) { - let filter = `path = "${path}"`; + private async getLastUpdatedTag(paths: string[], workspace: string) { + + let filter = ''; + const pathsFilter: string[] = []; + paths.forEach((path: string) => { + pathsFilter.push(`path = "${path}"`); + }) + const pathsFilterStr = pathsFilter.join(' OR '); + filter += `(${pathsFilter.join(' OR ')})`; if (workspace) { - filter += ` && workspace = "${workspace}"`; + filter += ` AND workspace = "${workspace}"`; } - const response = await this.post(this.tagUrl + '/query-tags-with-values', { filter, - take: 1, + take: 100, orderBy: 'TIMESTAMP', descending: true, }); - return throwIfNullish(response.tagsWithValues[0], `No tags matched the path '${path}'`); + return response.tagsWithValues.length ? response.tagsWithValues : Throw(`No tags matched the path '${pathsFilterStr}'`) + } + + private async getTagHistoryWithChunks(paths: string[], workspace: string, range: TimeRange, intervals?: number): Promise { + const pathChunks: string[][] = []; + for (let i = 0; i < paths.length; i += 10) { + pathChunks.push(paths.slice(i, i + 10)); + } + // Fetch and aggregate the data from each chunk + const aggregatedResults: TagHistoryResponse = { results: {} }; + for (const chunk of pathChunks) { + const chunkResult = await this.getTagHistoryValues(chunk, workspace, range, intervals); + // Merge the results from the current chunk with the aggregated results + for (const [path, data] of Object.entries(chunkResult.results)) { + if (!aggregatedResults.results[path]) { + aggregatedResults.results[path] = data; + } else { + aggregatedResults.results[path].values = aggregatedResults.results[path].values.concat(data.values); + } + } + } + + return aggregatedResults; } - private async getTagHistoryValues(path: string, workspace: string, range: TimeRange, intervals?: number) { - const response = await this.post(this.tagHistoryUrl + '/query-decimated-history', { - paths: [path], - workspace, + getTagHistoryValues = async (paths: string[], workspace: string, range: TimeRange, intervals?: number): Promise => { + return this.post(`${this.tagHistoryUrl}/query-decimated-history`, { + paths: paths, + workspace: workspace, startTime: range.from.toISOString(), endTime: range.to.toISOString(), decimation: intervals ? Math.min(intervals, 1000) : 500, }); + }; - const { type, values } = response.results[path]; - return { - datetimes: values.map(v => dateTime(v.timestamp).valueOf()), - values: values.map(v => this.convertTagValue(type, v.value)), - }; - } - - private convertTagValue(type: string, value?: string) { - return value && ['DOUBLE', 'INT', 'U_INT64'].includes(type) ? Number(value) : value; + private convertTagValue(type: TagDataType, value?: string) { + return value && [TagDataType.DOUBLE, TagDataType.INT, TagDataType.U_INT64].includes(type) ? Number(value) : value; } - private getPropertiesAsFields(properties: Record | null): FieldDTO[] { - if (!properties) { - return []; - } + private getAllProperties(data: TagWithValue[]) { + let props: Set = new Set(); + data.forEach((tag) => { + if (tag.tag.properties) { + Object.keys(tag.tag.properties) + .filter(name => !name.startsWith('nitag')) + .forEach((name) => { + props.add(name) + }) + } + }) - return Object.keys(properties) - .filter(name => !name.startsWith('nitag')) - .map(name => ({ name, values: [properties[name]] })); + return props } shouldRunQuery(query: TagQuery): boolean { return Boolean(query.path); } + async metricFindQuery({ workspace, path }: TagVariableQuery): Promise { + // Skip querying when no paths are provided + if (!path) { + return [] + } + let paths: string[] = [path]; + // wrap replaced variable in extra curly braces, protecting original {} tags from misinterpretation. + let parsed_workspace = this.templateSrv.replace(workspace,); + if (this.templateSrv.containsTemplate(path)) { + let replaced_path = this.templateSrv.replace(path, {}, (v: string) => `{{${v}}}`); + paths = expandMultipleValueVariableAfterReplace(replaced_path); + } + const metadata = await this.getLastUpdatedTag(paths, parsed_workspace); + return metadata.map((frame) => { + return { + text: frame.tag.properties?.displayName ? frame.tag.properties['displayName'] : frame.tag.path, + value: frame.tag.path + } + }); + } + async testDatasource(): Promise { await this.get(this.tagUrl + '/tags-count'); return { status: 'success', message: 'Data source connected and authentication successful!' }; } + + mergeTagsHistoryValues = (history: Record): TimeAndTagTypeValues => { + const timestampsSet: Set = new Set(); + const values: TimeAndTagTypeValues = { + timestamps: [], + values: {} + }; + for (const path in history) { + for (const { timestamp } of history[path].values) { + timestampsSet.add(timestamp); + } + } + //uniq timestamps from history data + const timestamps = [...timestampsSet]; + // Sort timestamps to ensure a consistent order + timestamps.sort(); + values.timestamps = timestamps; + + // Initialize arrays for each key + for (const path in history) { + values.values[path] = { 'type': history[path].type, 'values': new Array(timestamps.length).fill(null) }; + } + // Populate the values arrays + for (const path in history) { + for (const historicalValue of history[path].values) { + const index = timestamps.indexOf(historicalValue.timestamp); + values.values[path]['values'][index] = historicalValue.value; + } + } + + return values; + } } diff --git a/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap b/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap index 2340ab8a..f662a1a6 100644 --- a/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap +++ b/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap @@ -5,7 +5,13 @@ exports[`queries appends tag properties to query result 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -37,7 +43,13 @@ exports[`queries applies query defaults when missing fields 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -63,7 +75,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag1", + "name": "name", + "values": [ + "tag1", + ], + }, + { + "name": "current_value", "values": [ 3, ], @@ -84,7 +102,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag2", + "name": "name", + "values": [ + "tag2", + ], + }, + { + "name": "current_value", "values": [ 3.3, ], @@ -105,7 +129,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag3", + "name": "name", + "values": [ + "tag3", + ], + }, + { + "name": "current_value", "values": [ "foo", ], @@ -126,7 +156,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag4", + "name": "name", + "values": [ + "tag4", + ], + }, + { + "name": "current_value", "values": [ "True", ], @@ -147,7 +183,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag5", + "name": "name", + "values": [ + "tag5", + ], + }, + { + "name": "current_value", "values": [ 2147483648, ], @@ -173,7 +215,13 @@ exports[`queries handles null tag properties 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -199,7 +247,13 @@ exports[`queries handles tag with no current value 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ undefined, ], @@ -225,7 +279,13 @@ exports[`queries multiple targets - skips invalid queries 1`] = ` { "fields": [ { - "name": "my.tag1", + "name": "name", + "values": [ + "my.tag1", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -246,7 +306,13 @@ exports[`queries multiple targets - skips invalid queries 1`] = ` { "fields": [ { - "name": "my.tag2", + "name": "name", + "values": [ + "my.tag2", + ], + }, + { + "name": "current_value", "values": [ 41.3, ], @@ -273,12 +339,14 @@ exports[`queries numeric tag history 1`] = ` "fields": [ { "name": "time", + "type": "time", "values": [ - 1672531200000, - 1672531260000, + "2023-01-01T00:00:00Z", + "2023-01-01T00:01:00Z", ], }, { + "config": {}, "name": "my.tag", "values": [ 1, @@ -296,7 +364,13 @@ exports[`queries replaces tag path with variable 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -323,12 +397,14 @@ exports[`queries string tag history 1`] = ` "fields": [ { "name": "time", + "type": "time", "values": [ - 1672531200000, - 1672531260000, + "2023-01-01T00:00:00Z", + "2023-01-01T00:01:00Z", ], }, { + "config": {}, "name": "my.tag", "values": [ "3.14", @@ -346,7 +422,13 @@ exports[`queries supports legacy tag service property "datatype" 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -372,7 +454,13 @@ exports[`queries tag current value 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], @@ -398,7 +486,13 @@ exports[`queries uses displayName property 1`] = ` { "fields": [ { - "name": "My cool tag", + "name": "name", + "values": [ + "My cool tag", + ], + }, + { + "name": "current_value", "values": [ 3.14, ], diff --git a/src/datasources/tag/components/TagQueryEditor.tsx b/src/datasources/tag/components/TagQueryEditor.tsx index ec6c08a1..ee6d427b 100644 --- a/src/datasources/tag/components/TagQueryEditor.tsx +++ b/src/datasources/tag/components/TagQueryEditor.tsx @@ -2,7 +2,7 @@ import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { AutoSizeInput, InlineSwitch, RadioButtonGroup, Select } from '@grafana/ui'; import { InlineField } from 'core/components/InlineField'; import { enumToOptions, useWorkspaceOptions } from 'core/utils'; -import React, { FormEvent } from 'react'; +import React, { FormEvent, JSX } from 'react'; import { TagDataSource } from '../TagDataSource'; import { TagQuery, TagQueryType } from '../types'; @@ -33,13 +33,25 @@ export function TagQueryEditor({ query, onChange, onRunQuery, datasource }: Prop onRunQuery(); }; + const EnablePropertiesComponent: JSX.Element | null = query.type !== TagQueryType.History ? + + + : null + return ( <> - + - + ) => onChange({ ...query, workspace: option?.value ?? '' })} + options={workspaces.value} + placeholder="Any workspace" + value={query.workspace} + /> + + + ); +} + +const tooltips = { + tagPath: `Specify a path to find matching tags. You can enter a variable or glob-style wildcards into this field.`, + + workspace: `The workspace to search for the given tag path. If left blank, the plugin + finds the most recently updated tag in any workspace.`, +}; diff --git a/src/datasources/tag/module.ts b/src/datasources/tag/module.ts index 34004748..52a88c33 100644 --- a/src/datasources/tag/module.ts +++ b/src/datasources/tag/module.ts @@ -2,7 +2,9 @@ import { DataSourcePlugin } from '@grafana/data'; import { TagDataSource } from './TagDataSource'; import { TagQueryEditor } from './components/TagQueryEditor'; import { HttpConfigEditor } from 'core/components/HttpConfigEditor'; +import { TagVariableQueryEditor } from "./components/TagVariableQueryEditor"; export const plugin = new DataSourcePlugin(TagDataSource) .setConfigEditor(HttpConfigEditor) - .setQueryEditor(TagQueryEditor); + .setQueryEditor(TagQueryEditor) + .setVariableQueryEditor(TagVariableQueryEditor); diff --git a/src/datasources/tag/types.ts b/src/datasources/tag/types.ts index 8ddab11e..3af0ec95 100644 --- a/src/datasources/tag/types.ts +++ b/src/datasources/tag/types.ts @@ -12,6 +12,11 @@ export interface TagQuery extends DataQuery { properties: boolean; } +export interface TagVariableQuery { + path: string; + workspace: string; +} + interface TagWithValueBase { current: { value: { value: string }; @@ -27,7 +32,7 @@ interface TagWithValueBase { interface TagWithValueV1 { tag: { collect_aggregates: boolean; - datatype: string; + datatype: TagDataType; last_updated: number; workspace_id: string; } @@ -36,7 +41,7 @@ interface TagWithValueV1 { // Tag properties renamed in SystemLink Server and Enterprise interface TagWithValueV2 { tag: { - type: string; + type: TagDataType; workspace: string; } } @@ -47,11 +52,30 @@ export interface TagsWithValues { tagsWithValues: TagWithValue[]; } +export interface HttpHistoricalValue { + timestamp: string; + value: string +} + +export interface TypeAndValues { + type: TagDataType; + values: HttpHistoricalValue[]; +} + export interface TagHistoryResponse { - results: { - [path: string]: { - type: string; - values: Array<{ timestamp: string; value: string }>; - }; - }; + results: Record +} + +export enum TagDataType { + DOUBLE = "DOUBLE", + INT = "INT", + STRING = "STRING", + BOOLEAN = "BOOLEAN", + U_INT64 = "U_INT64", + DATE_TIME = "DATE_TIME" +} + +export interface TimeAndTagTypeValues { + timestamps: string[], + values: Record } From caa930f5e36619e16a8bd8a8b5a620ee620fa206 Mon Sep 17 00:00:00 2001 From: Tigran Vardanyan Date: Wed, 24 Apr 2024 16:17:03 +0400 Subject: [PATCH 2/2] minor changes --- src/datasources/tag/TagDataSource.ts | 32 +++++++++++-------- .../__snapshots__/TagDataSource.test.ts.snap | 30 ++++++++--------- src/datasources/tag/types.ts | 4 +-- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index e417d739..a29353c8 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -45,14 +45,16 @@ export class TagDataSource extends DataSourceBase { async runQuery(query: TagQuery, { range, maxDataPoints, scopedVars }: DataQueryRequest): Promise { let paths: string[] = [query.path]; if (this.templateSrv.containsTemplate(query.path)) { - // wrap replaced variable in extra curly braces, protecting original {} tags from misinterpretation. - const replaced_path = this.templateSrv.replace(query.path, {}, (a: string) => `{{${a}}}`); - paths = expandMultipleValueVariableAfterReplace(replaced_path); + // wrap replaced variable in extra curly braces, protecting original {} in tag path from misinterpretation. + const replacedPath = this.templateSrv.replace( + query.path, + {}, + (v: string | string[]): string => `{{${v}}}` + ); + paths = expandMultipleValueVariableAfterReplace(replacedPath); } const workspace = this.templateSrv.replace(query.workspace, scopedVars); - const tagsLastUpdates: TagWithValue[] = await this.getLastUpdatedTag(paths, workspace); - const result: DataFrameDTO = { refId: query.refId, fields: [] }; if (query.type === TagQueryType.Current) { @@ -63,7 +65,7 @@ export class TagDataSource extends DataSourceBase { values: tagsLastUpdates.map((tag: TagWithValue) => tag.tag.properties?.displayName || tag.tag.path) }, { - name: 'current_value', + name: 'currentValue', values: tagsLastUpdates.map((tag: TagWithValue) => this.convertTagValue(tag.tag.type ?? tag.tag.datatype, tag.current?.value.value)), }, { @@ -83,6 +85,7 @@ export class TagDataSource extends DataSourceBase { ); }); } + return result } else { const tagPropertiesMap: Record | null> = {}; @@ -91,7 +94,7 @@ export class TagDataSource extends DataSourceBase { }); const workspaceFromResponse = tagsLastUpdates[0].tag.workspace ?? tagsLastUpdates[0].tag.workspace_id; const tagHistoryResponse = await this.getTagHistoryWithChunks(Object.keys(tagPropertiesMap), workspaceFromResponse, range, maxDataPoints); - let mergedTagValuesWithType = this.mergeTagsHistoryValues(tagHistoryResponse.results); + const mergedTagValuesWithType = this.mergeTagsHistoryValues(tagHistoryResponse.results); result.fields.push({ name: 'time', 'values': mergedTagValuesWithType.timestamps, type: FieldType.time }); @@ -120,7 +123,6 @@ export class TagDataSource extends DataSourceBase { } private async getLastUpdatedTag(paths: string[], workspace: string) { - let filter = ''; const pathsFilter: string[] = []; paths.forEach((path: string) => { @@ -178,7 +180,7 @@ export class TagDataSource extends DataSourceBase { } private getAllProperties(data: TagWithValue[]) { - let props: Set = new Set(); + const props: Set = new Set(); data.forEach((tag) => { if (tag.tag.properties) { Object.keys(tag.tag.properties) @@ -203,12 +205,16 @@ export class TagDataSource extends DataSourceBase { } let paths: string[] = [path]; // wrap replaced variable in extra curly braces, protecting original {} tags from misinterpretation. - let parsed_workspace = this.templateSrv.replace(workspace,); + const parsedWorkspace = this.templateSrv.replace(workspace); if (this.templateSrv.containsTemplate(path)) { - let replaced_path = this.templateSrv.replace(path, {}, (v: string) => `{{${v}}}`); - paths = expandMultipleValueVariableAfterReplace(replaced_path); + const replacedPath = this.templateSrv.replace( + path, + {}, + (v: string | string[]): string => `{{${v}}}` + ); + paths = expandMultipleValueVariableAfterReplace(replacedPath); } - const metadata = await this.getLastUpdatedTag(paths, parsed_workspace); + const metadata = await this.getLastUpdatedTag(paths, parsedWorkspace); return metadata.map((frame) => { return { text: frame.tag.properties?.displayName ? frame.tag.properties['displayName'] : frame.tag.path, diff --git a/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap b/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap index f662a1a6..219964e9 100644 --- a/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap +++ b/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap @@ -11,7 +11,7 @@ exports[`queries appends tag properties to query result 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -49,7 +49,7 @@ exports[`queries applies query defaults when missing fields 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -81,7 +81,7 @@ exports[`queries current value for all data types 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3, ], @@ -108,7 +108,7 @@ exports[`queries current value for all data types 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.3, ], @@ -135,7 +135,7 @@ exports[`queries current value for all data types 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ "foo", ], @@ -162,7 +162,7 @@ exports[`queries current value for all data types 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ "True", ], @@ -189,7 +189,7 @@ exports[`queries current value for all data types 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 2147483648, ], @@ -221,7 +221,7 @@ exports[`queries handles null tag properties 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -253,7 +253,7 @@ exports[`queries handles tag with no current value 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ undefined, ], @@ -285,7 +285,7 @@ exports[`queries multiple targets - skips invalid queries 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -312,7 +312,7 @@ exports[`queries multiple targets - skips invalid queries 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 41.3, ], @@ -370,7 +370,7 @@ exports[`queries replaces tag path with variable 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -428,7 +428,7 @@ exports[`queries supports legacy tag service property "datatype" 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -460,7 +460,7 @@ exports[`queries tag current value 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], @@ -492,7 +492,7 @@ exports[`queries uses displayName property 1`] = ` ], }, { - "name": "current_value", + "name": "currentValue", "values": [ 3.14, ], diff --git a/src/datasources/tag/types.ts b/src/datasources/tag/types.ts index 3af0ec95..55454e40 100644 --- a/src/datasources/tag/types.ts +++ b/src/datasources/tag/types.ts @@ -52,14 +52,14 @@ export interface TagsWithValues { tagsWithValues: TagWithValue[]; } -export interface HttpHistoricalValue { +export interface TimestampedValue { timestamp: string; value: string } export interface TypeAndValues { type: TagDataType; - values: HttpHistoricalValue[]; + values: TimestampedValue[]; } export interface TagHistoryResponse {