From 9ac340acf493220008382653cac3c7df08ef4bd3 Mon Sep 17 00:00:00 2001 From: Tigran Vardanyan <44769443+TigranVardanyan@users.noreply.github.com> Date: Tue, 7 May 2024 21:29:08 +0400 Subject: [PATCH] feat(tag): visualizing current value of multiple tags (#58) Co-authored-by: Carson --- src/datasources/tag/TagDataSource.test.ts | 113 +++++++--- src/datasources/tag/TagDataSource.ts | 61 ++++- .../__snapshots__/TagDataSource.test.ts.snap | 211 ++++++++++++++++-- 3 files changed, 334 insertions(+), 51 deletions(-) diff --git a/src/datasources/tag/TagDataSource.test.ts b/src/datasources/tag/TagDataSource.test.ts index 45860be..ff059cc 100644 --- a/src/datasources/tag/TagDataSource.test.ts +++ b/src/datasources/tag/TagDataSource.test.ts @@ -63,15 +63,37 @@ describe('queries', () => { }); test('uses displayName property', async () => { - backendSrv.fetch.mockReturnValue(createQueryTagsResponse({ properties: { displayName: 'My cool tag' } })); + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([{ tag: { properties: { displayName: 'My cool tag' } } }])); const result = await ds.query(buildQuery({ path: 'my.tag' })); expect(result.data).toMatchSnapshot(); }); + test('multiple current values with properties', async () => { + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([ + { tag: { path: 'my.1.tag', properties: { unit: 'A' } } }, + { tag: { path: 'my.2.tag', properties: { unit: 'A' } }, current: { value: { value: '41.3' } } } + ])); + + const result = await ds.query(buildQuery({ path: 'my.*.tag', properties: true })); + + expect(result.data).toMatchSnapshot(); + }); + + test('multiple current values with different properties', async () => { + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([ + { tag: { path: 'my.1.tag', properties: { upperCriticalThreshold: '15' } } }, + { tag: { path: 'my.2.tag', properties: { unit: 'A' } }, current: { value: { value: '41.3' } } } + ])); + + const result = await ds.query(buildQuery({ path: 'my.*.tag', properties: true })); + + expect(result.data).toMatchSnapshot(); + }); + test('handles null tag properties', async () => { - backendSrv.fetch.mockReturnValue(createQueryTagsResponse({ properties: null })); + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([{ tag: { properties: null } }])); const result = await ds.query(buildQuery({ path: 'my.tag' })); @@ -79,7 +101,7 @@ describe('queries', () => { }); test('handles tag with no current value', async () => { - backendSrv.fetch.mockReturnValue(createQueryTagsResponse({}, null)); + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([{ tag: {}, current: null }])); const result = await ds.query(buildQuery({ path: 'my.tag' })); @@ -89,10 +111,10 @@ describe('queries', () => { test('multiple targets - skips invalid queries', async () => { backendSrv.fetch .calledWith(requestMatching({ data: { filter: 'path = "my.tag1"' } })) - .mockReturnValue(createQueryTagsResponse({ path: 'my.tag1' })); + .mockReturnValue(createQueryTagsResponse([{ tag: { path: 'my.tag1' } }])); backendSrv.fetch .calledWith(requestMatching({ data: { filter: 'path = "my.tag2"' } })) - .mockReturnValue(createQueryTagsResponse({ path: 'my.tag2' }, { value: { value: '41.3' } })); + .mockReturnValue(createQueryTagsResponse([{ tag: { path: 'my.tag2' }, current: { value: { value: '41.3' } } }])); const result = await ds.query(buildQuery({ path: 'my.tag1' }, { path: '' }, { path: 'my.tag2' })); @@ -101,12 +123,27 @@ 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([{ + tag: { type: 'INT', path: 'tag1' }, + current: { value: { value: '3' } } + }])) + .mockReturnValueOnce(createQueryTagsResponse([{ + tag: { type: 'DOUBLE', path: 'tag2' }, + current: { value: { value: '3.3' } } + }])) + .mockReturnValueOnce(createQueryTagsResponse([{ + tag: { type: 'STRING', path: 'tag3' }, + current: { value: { value: 'foo' } } + }])) + .mockReturnValueOnce(createQueryTagsResponse([{ + tag: { type: 'BOOLEAN', path: 'tag4' }, + current: { value: { value: 'True' } } + }])) .mockReturnValueOnce( - createQueryTagsResponse({ type: 'U_INT64', path: 'tag5' }, { value: { value: '2147483648' } }) + createQueryTagsResponse([{ + tag: { type: 'U_INT64', path: 'tag5' }, + current: { value: { value: '2147483648' } } + }]) ); const result = await ds.query( @@ -117,7 +154,7 @@ describe('queries', () => { }); test('throw when no tags matched', async () => { - backendSrv.fetch.mockReturnValue(createFetchResponse({ tagsWithValues: [] })); + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([])); await expect(ds.query(buildQuery({ path: 'my.tag' }))).rejects.toThrow("No tags matched the path 'my.tag'"); }); @@ -198,7 +235,7 @@ describe('queries', () => { }); test('filters by workspace if provided', async () => { - backendSrv.fetch.mockReturnValueOnce(createQueryTagsResponse({ workspace: '2' })); + backendSrv.fetch.mockReturnValueOnce(createQueryTagsResponse([{ tag: { workspace: '2' } }])); backendSrv.fetch.mockReturnValueOnce(createTagHistoryResponse('my.tag', 'DOUBLE', [])); await ds.query(buildQuery({ type: TagQueryType.History, path: 'my.tag', workspace: '2' })); @@ -230,7 +267,14 @@ describe('queries', () => { }); test('appends tag properties to query result', async () => { - backendSrv.fetch.mockReturnValue(createQueryTagsResponse({ properties: { nitagHistoryTTLDays: '7', foo: 'bar' } })); + backendSrv.fetch.mockReturnValue(createQueryTagsResponse([{ + tag: { + properties: { + nitagHistoryTTLDays: '7', + foo: 'bar' + } + } + }])); const result = await ds.query(buildQuery({ path: 'my.tag', properties: true })); @@ -281,20 +325,37 @@ describe('queries', () => { }); function createQueryTagsResponse( - tag?: DeepPartial, - current?: DeepPartial + tags?: Array<{ + tag?: DeepPartial, + current?: DeepPartial + }> ) { - return createFetchResponse({ - tagsWithValues: [ - _.defaultsDeep( - { tag, current }, - { - current: { value: { value: '3.14' }, timestamp: '2023-10-04T00:00:00.000000Z' }, - tag: { type: 'DOUBLE', path: 'my.tag', properties: {}, workspace: '1' }, - } - ), - ], - }); + if (tags) { + if (tags?.length) { + return createFetchResponse({ + tagsWithValues: [ + ...tags.map(({ tag, current }) => _.defaultsDeep( + { tag, current }, + { + current: { value: { value: '3.14' }, timestamp: '2023-10-04T00:00:00.000000Z' }, + tag: { type: 'DOUBLE', path: 'my.tag', properties: {}, workspace: '1' }, + } + )) + ], + }); + } else { + return createFetchResponse({ + tagsWithValues: [], + }); + } + } else { + return createFetchResponse({ + tagsWithValues: [{ + current: { value: { value: '3.14' }, timestamp: '2023-10-04T00:00:00.000000Z' }, + tag: { type: 'DOUBLE', path: 'my.tag', properties: {}, workspace: '1' }, + }], + }); + } } function createTagHistoryResponse(path: string, type: string, values: Array<{ timestamp: string; value: string }>) { diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index d6d45d6..aceb1c9 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -10,8 +10,8 @@ import { } 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 { TagHistoryResponse, TagQuery, TagQueryType, TagsWithValues, TagWithValue } from './types'; +import { Throw } from 'core/utils'; export class TagDataSource extends DataSourceBase { constructor( @@ -25,7 +25,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,19 +33,45 @@ export class TagDataSource extends DataSourceBase { }; async runQuery(query: TagQuery, { range, maxDataPoints, scopedVars }: DataQueryRequest): Promise { - const { tag, current } = await this.getLastUpdatedTag( + const tagsWithValues = await this.getMostRecentTags( this.templateSrv.replace(query.path, scopedVars), this.templateSrv.replace(query.workspace, scopedVars) ); + const tag = tagsWithValues[0].tag const name = tag.properties?.displayName ?? tag.path; const result: DataFrameDTO = { refId: query.refId, fields: [] }; if (query.type === TagQueryType.Current) { + const allPossibleProps = this.getAllProperties(tagsWithValues); 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: tagsWithValues.map((tag: TagWithValue) => tag.tag.properties?.displayName || tag.tag.path) + }, + { + name: 'value', + values: tagsWithValues.map((tag: TagWithValue) => this.convertTagValue(tag.tag.type ?? tag.tag.datatype, tag.current?.value.value)), + }, + { + name: 'updated', + values: tagsWithValues.map((tag: TagWithValue) => tag.current?.timestamp), + type: FieldType.time, + config: { unit: 'dateTimeFromNow' } + } + ] + if (query.properties) { + allPossibleProps.forEach((prop) => { + result.fields.push( + { + name: prop, + values: tagsWithValues.map((tag: TagWithValue) => tag.tag.properties && tag.tag.properties[prop] ? tag.tag.properties[prop] : '') + } + ); + }); + } + + return result } else { const history = await this.getTagHistoryValues(tag.path, tag.workspace ?? tag.workspace_id, range, maxDataPoints); result.fields = [ @@ -61,7 +87,7 @@ export class TagDataSource extends DataSourceBase { return result; } - private async getLastUpdatedTag(path: string, workspace: string) { + private async getMostRecentTags(path: string, workspace: string) { let filter = `path = "${path}"`; if (workspace) { filter += ` && workspace = "${workspace}"`; @@ -69,12 +95,12 @@ export class TagDataSource extends DataSourceBase { const response = await this.post(this.tagUrl + '/query-tags-with-values', { filter, - take: 1, + take: 32, 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 '${path}'`) } private async getTagHistoryValues(path: string, workspace: string, range: TimeRange, intervals?: number) { @@ -107,6 +133,21 @@ export class TagDataSource extends DataSourceBase { .map(name => ({ name, values: [properties[name]] })); } + private getAllProperties(data: TagWithValue[]) { + const 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 props + } + shouldRunQuery(query: TagQuery): boolean { return Boolean(query.path); } diff --git a/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap b/src/datasources/tag/__snapshots__/TagDataSource.test.ts.snap index 2340ab8..dfca11b 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": "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": "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": "value", "values": [ 3, ], @@ -84,7 +102,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag2", + "name": "name", + "values": [ + "tag2", + ], + }, + { + "name": "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": "value", "values": [ "foo", ], @@ -126,7 +156,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag4", + "name": "name", + "values": [ + "tag4", + ], + }, + { + "name": "value", "values": [ "True", ], @@ -147,7 +183,13 @@ exports[`queries current value for all data types 1`] = ` { "fields": [ { - "name": "tag5", + "name": "name", + "values": [ + "tag5", + ], + }, + { + "name": "value", "values": [ 2147483648, ], @@ -173,7 +215,13 @@ exports[`queries handles null tag properties 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "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": "value", "values": [ undefined, ], @@ -220,12 +274,109 @@ exports[`queries handles tag with no current value 1`] = ` ] `; +exports[`queries multiple current values with properties 1`] = ` +[ + { + "fields": [ + { + "name": "name", + "values": [ + "my.1.tag", + "my.2.tag", + ], + }, + { + "name": "value", + "values": [ + 3.14, + 41.3, + ], + }, + { + "config": { + "unit": "dateTimeFromNow", + }, + "name": "updated", + "type": "time", + "values": [ + "2023-10-04T00:00:00.000000Z", + "2023-10-04T00:00:00.000000Z", + ], + }, + { + "name": "unit", + "values": [ + "A", + "A", + ], + }, + ], + "refId": "A", + }, +] +`; + +exports[`queries multiple current values with different properties 1`] = ` +[ + { + "fields": [ + { + "name": "name", + "values": [ + "my.1.tag", + "my.2.tag", + ], + }, + { + "name": "value", + "values": [ + 3.14, + 41.3, + ], + }, + { + "config": { + "unit": "dateTimeFromNow", + }, + "name": "updated", + "type": "time", + "values": [ + "2023-10-04T00:00:00.000000Z", + "2023-10-04T00:00:00.000000Z", + ], + }, + { + "name": "upperCriticalThreshold", + "values": [ + "15", + "", + ], + }, + { + "name": "unit", + "values": [ + "", + "A", + ], + }, + ], + "refId": "A", + }, +] +`; + exports[`queries multiple targets - skips invalid queries 1`] = ` [ { "fields": [ { - "name": "my.tag1", + "name": "name", + "values": [ + "my.tag1", + ], + }, + { + "name": "value", "values": [ 3.14, ], @@ -246,7 +397,13 @@ exports[`queries multiple targets - skips invalid queries 1`] = ` { "fields": [ { - "name": "my.tag2", + "name": "name", + "values": [ + "my.tag2", + ], + }, + { + "name": "value", "values": [ 41.3, ], @@ -296,7 +453,13 @@ exports[`queries replaces tag path with variable 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "value", "values": [ 3.14, ], @@ -346,7 +509,13 @@ exports[`queries supports legacy tag service property "datatype" 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "value", "values": [ 3.14, ], @@ -372,7 +541,13 @@ exports[`queries tag current value 1`] = ` { "fields": [ { - "name": "my.tag", + "name": "name", + "values": [ + "my.tag", + ], + }, + { + "name": "value", "values": [ 3.14, ], @@ -398,7 +573,13 @@ exports[`queries uses displayName property 1`] = ` { "fields": [ { - "name": "My cool tag", + "name": "name", + "values": [ + "My cool tag", + ], + }, + { + "name": "value", "values": [ 3.14, ],