Skip to content

Commit

Permalink
feat(tag): visualizing current value of multiple tags (#58)
Browse files Browse the repository at this point in the history
Co-authored-by: Carson <carson.moore@ni.com>
  • Loading branch information
TigranVardanyan and mure authored May 7, 2024
1 parent cb3c523 commit 9ac340a
Show file tree
Hide file tree
Showing 3 changed files with 334 additions and 51 deletions.
113 changes: 87 additions & 26 deletions src/datasources/tag/TagDataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,23 +63,45 @@ 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' }));

expect(result.data).toMatchSnapshot();
});

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' }));

Expand All @@ -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' }));

Expand All @@ -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(
Expand All @@ -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'");
});
Expand Down Expand Up @@ -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' }));
Expand Down Expand Up @@ -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 }));

Expand Down Expand Up @@ -281,20 +325,37 @@ describe('queries', () => {
});

function createQueryTagsResponse(
tag?: DeepPartial<TagWithValue['tag']>,
current?: DeepPartial<TagWithValue['current']>
tags?: Array<{
tag?: DeepPartial<TagWithValue['tag']>,
current?: DeepPartial<TagWithValue['current']>
}>
) {
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 }>) {
Expand Down
61 changes: 51 additions & 10 deletions src/datasources/tag/TagDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TagQuery> {
constructor(
Expand All @@ -25,27 +25,53 @@ export class TagDataSource extends DataSourceBase<TagQuery> {
tagUrl = this.instanceSettings.url + '/nitag/v2';
tagHistoryUrl = this.instanceSettings.url + '/nitaghistorian/v2/tags';

defaultQuery = {
defaultQuery: Omit<TagQuery, 'refId'> = {
type: TagQueryType.Current,
path: '',
workspace: '',
properties: false,
};

async runQuery(query: TagQuery, { range, maxDataPoints, scopedVars }: DataQueryRequest): Promise<DataFrameDTO> {
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 = [
Expand All @@ -61,20 +87,20 @@ export class TagDataSource extends DataSourceBase<TagQuery> {
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}"`;
}

const response = await this.post<TagsWithValues>(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) {
Expand Down Expand Up @@ -107,6 +133,21 @@ export class TagDataSource extends DataSourceBase<TagQuery> {
.map(name => ({ name, values: [properties[name]] }));
}

private getAllProperties(data: TagWithValue[]) {
const props: Set<string> = 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);
}
Expand Down
Loading

0 comments on commit 9ac340a

Please sign in to comment.