Skip to content

Commit

Permalink
feat(tag): add support for history queries (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
mure authored Aug 17, 2023
1 parent 8ceee7c commit aafd782
Show file tree
Hide file tree
Showing 10 changed files with 317 additions and 65 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ e2e-results/
.idea

.eslintcache

provisioning/datasources/datasources.yaml
4 changes: 3 additions & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
module.exports = {
// Prettier configuration provided by Grafana scaffolding
...require("./.config/.prettierrc.js")
...require("./.config/.prettierrc.js"),

arrowParens: 'avoid'
};
31 changes: 31 additions & 0 deletions provisioning/example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Do not edit this file directly - it is a template! Copy it to
# provisioning/datasources/datasources.yaml and fill in the SystemLink server
# url and your API key.

apiVersion: 1

config: &config
access: proxy
url: MY_SYSTEMLINK_API_URL
jsonData:
httpHeaderName1: 'x-ni-api-key'
secureJsonData:
httpHeaderValue1: MY_SYSTEMLINK_API_KEY

datasources:
- name: SystemLink Tags
type: ni-sltag-datasource-prerelease
uid: tag
<<: *config
- name: SystemLink Data Frames
type: ni-sldataframe-datasource
uid: dataframe
<<: *config
- name: SystemLink Notebooks
type: ni-slnotebook-datasource
uid: notebook
<<: *config
- name: SystemLink Systems
type: ni-slsystem-datasource
uid: system
<<: *config
28 changes: 28 additions & 0 deletions src/core/DataSourceBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
DataFrameDTO,
DataQueryRequest,
DataQueryResponse,
DataSourceApi
} from '@grafana/data';
import { TestingStatus } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';

export abstract class DataSourceBase<TQuery extends DataQuery> extends DataSourceApi<TQuery> {
query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> {
const promises = request.targets
.map(this.prepareQuery, this)
.filter(this.shouldRunQuery, this)
.map(q => this.runQuery(q, request), this);

return Promise.all(promises).then(data => ({ data }));
}

prepareQuery(query: TQuery): TQuery {
return { ...this.defaultQuery, ...query };
}

abstract defaultQuery: Partial<TQuery> & Omit<TQuery, 'refId'>;
abstract runQuery(query: TQuery, options: DataQueryRequest): Promise<DataFrameDTO>;
abstract shouldRunQuery(query: TQuery): boolean;
abstract testDatasource(): Promise<TestingStatus>;
}
127 changes: 119 additions & 8 deletions src/datasources/tag/TagDataSource.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { MockProxy } from 'jest-mock-extended';
import { TagDataSource } from './TagDataSource';
import { createQueryRequest, createDataSource, createFetchError } from 'test/fixtures';
import { createQueryRequest, setupDataSource, createFetchError } from 'test/fixtures';
import { BackendSrv } from '@grafana/runtime';
import { TagsWithValues } from './types';
import { TagHistoryResponse, TagQuery, TagQueryType, TagsWithValues } from './types';

let ds: TagDataSource, backendSrv: MockProxy<BackendSrv>;

beforeEach(() => {
[ds, backendSrv] = createDataSource(TagDataSource);
[ds, backendSrv] = setupDataSource(TagDataSource);
});

describe('testDatasource', () => {
Expand All @@ -32,7 +32,7 @@ describe('queries', () => {
.calledWith('/nitag/v2/query-tags-with-values', expect.objectContaining({ filter: 'path = "my.tag"' }))
.mockResolvedValue(createQueryTagsResponse('my.tag', '3.14'));

const result = await ds.query(createQueryRequest({ path: 'my.tag' }));
const result = await ds.query(createQueryRequest({ type: TagQueryType.Current, path: 'my.tag' }));

expect(result.data).toEqual([
{
Expand All @@ -43,10 +43,18 @@ describe('queries', () => {
]);
});

test('applies query defaults when missing fields', async () => {
backendSrv.post.mockResolvedValue(createQueryTagsResponse('my.tag', '3.14'));

const result = await ds.query(createQueryRequest({ path: 'my.tag' } as TagQuery));

expect(result.data[0]).toHaveProperty('fields', [{ name: 'value', values: ['3.14'] }]);
});

test('uses displayName property', async () => {
backendSrv.post.mockResolvedValue(createQueryTagsResponse('my.tag', '3.14', 'My cool tag'));

const result = await ds.query(createQueryRequest({ path: 'my.tag' }));
const result = await ds.query(createQueryRequest({ type: TagQueryType.Current, path: 'my.tag' }));

expect(result.data[0]).toEqual(expect.objectContaining({ name: 'My cool tag' }));
});
Expand All @@ -56,7 +64,13 @@ describe('queries', () => {
.mockResolvedValueOnce(createQueryTagsResponse('my.tag1', '3.14'))
.mockResolvedValueOnce(createQueryTagsResponse('my.tag2', 'foo'));

const result = await ds.query(createQueryRequest({ path: 'my.tag1' }, { path: '' }, { path: 'my.tag2' }));
const result = await ds.query(
createQueryRequest(
{ type: TagQueryType.Current, path: 'my.tag1' },
{ type: TagQueryType.Current, path: '' },
{ type: TagQueryType.Current, path: 'my.tag2' }
)
);

expect(backendSrv.post.mock.calls[0][1]).toHaveProperty('filter', 'path = "my.tag1"');
expect(backendSrv.post.mock.calls[1][1]).toHaveProperty('filter', 'path = "my.tag2"');
Expand All @@ -77,10 +91,107 @@ describe('queries', () => {
test('throw when no tags matched', async () => {
backendSrv.post.mockResolvedValue({ tagsWithValues: [] });

await expect(ds.query(createQueryRequest({ path: 'my.tag' }))).rejects.toThrow('my.tag');
await expect(ds.query(createQueryRequest({ type: TagQueryType.Current, path: 'my.tag' }))).rejects.toThrow(
'my.tag'
);
});

test('numeric tag history', async () => {
const queryRequest = createQueryRequest<TagQuery>({ type: TagQueryType.History, path: 'my.tag' });

backendSrv.post
.calledWith('/nitag/v2/query-tags-with-values', expect.objectContaining({ filter: 'path = "my.tag"' }))
.mockResolvedValue(createQueryTagsResponse('my.tag', '3.14'));

backendSrv.post
.calledWith(
'/nitaghistorian/v2/tags/query-decimated-history',
expect.objectContaining({
paths: ['my.tag'],
workspace: '1',
startTime: queryRequest.range.from.toISOString(),
endTime: queryRequest.range.to.toISOString(),
decimation: 300,
})
)
.mockResolvedValue(
createTagHistoryResponse('my.tag', 'DOUBLE', [
{ timestamp: '2023-01-01T00:00:00Z', value: '1' },
{ timestamp: '2023-01-01T00:01:00Z', value: '2' },
])
);

const result = await ds.query(queryRequest);

expect(result.data).toEqual([
{
fields: [
{ name: 'time', values: [1672531200000, 1672531260000] },
{ name: 'value', values: [1, 2] },
],
name: 'my.tag',
refId: 'A',
},
]);
});

test('string tag history', async () => {
backendSrv.post.mockResolvedValueOnce(createQueryTagsResponse('my.tag', 'foo'));
backendSrv.post.mockResolvedValueOnce(
createTagHistoryResponse('my.tag', 'STRING', [
{ timestamp: '2023-01-01T00:00:00Z', value: '3.14' },
{ timestamp: '2023-01-01T00:01:00Z', value: 'foo' },
])
);

const result = await ds.query(createQueryRequest({ type: TagQueryType.History, path: 'my.tag' }));

expect(result.data).toEqual([
{
fields: [
{ name: 'time', values: [1672531200000, 1672531260000] },
{ name: 'value', values: ['3.14', 'foo'] },
],
name: 'my.tag',
refId: 'A',
},
]);
});

test('decimation parameter does not go above 1000', async () => {
const queryRequest = createQueryRequest<TagQuery>({ type: TagQueryType.History, path: 'my.tag' });
queryRequest.maxDataPoints = 1500;

backendSrv.post.mockResolvedValueOnce(createQueryTagsResponse('my.tag', '3'));

backendSrv.post.mockResolvedValueOnce(
createTagHistoryResponse('my.tag', 'INT', [
{ timestamp: '2023-01-01T00:00:00Z', value: '1' },
{ timestamp: '2023-01-01T00:01:00Z', value: '2' },
])
);

await ds.query(queryRequest);

expect(backendSrv.post.mock.lastCall?.[1]).toHaveProperty('decimation', 1000);
});
});

function createQueryTagsResponse(path: string, value: string, displayName?: string): TagsWithValues {
return { tagsWithValues: [{ current: { value: { value } }, tag: { path, properties: { displayName } } }] };
return {
tagsWithValues: [
{
current: { value: { value } },
tag: { path, properties: { displayName }, workspace_id: '1' },
},
],
};
}

function createTagHistoryResponse(
path: string,
type: string,
values: Array<{ timestamp: string; value: string }>
): TagHistoryResponse {
return { results: { [path]: { type, values } } };
}
78 changes: 50 additions & 28 deletions src/datasources/tag/TagDataSource.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,50 @@
import {
DataFrameDTO,
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
} from '@grafana/data';

import { DataFrameDTO, DataSourceInstanceSettings, dateTime, DataQueryRequest, TimeRange } from '@grafana/data';
import { BackendSrv, TestingStatus, getBackendSrv } from '@grafana/runtime';

import { TagQuery, TagsWithValues } from './types';
import { DataSourceBase } from 'core/DataSourceBase';
import { throwIfNullish } from 'core/utils';
import { TagHistoryResponse, TagQuery, TagQueryType, TagsWithValues } from './types';

export class TagDataSource extends DataSourceApi<TagQuery> {
baseUrl: string;
export class TagDataSource extends DataSourceBase<TagQuery> {
constructor(
private instanceSettings: DataSourceInstanceSettings,
private backendSrv: BackendSrv = getBackendSrv()
readonly instanceSettings: DataSourceInstanceSettings,
readonly backendSrv: BackendSrv = getBackendSrv()
) {
super(instanceSettings);
this.baseUrl = this.instanceSettings.url + '/nitag/v2';
}

async query(options: DataQueryRequest<TagQuery>): Promise<DataQueryResponse> {
return { data: await Promise.all(options.targets.filter(this.shouldRunQuery).map(this.runQuery, this)) };
}
tagUrl = this.instanceSettings.url + '/nitag/v2';
tagHistoryUrl = this.instanceSettings.url + '/nitaghistorian/v2/tags';

defaultQuery = {
type: TagQueryType.Current,
path: '',
};

private async runQuery(query: TagQuery): Promise<DataFrameDTO> {
async runQuery(query: TagQuery, { range, maxDataPoints }: DataQueryRequest): Promise<DataFrameDTO> {
const { tag, current } = await this.getLastUpdatedTag(query.path);
const name = tag.properties.displayName ?? tag.path;

if (query.type === TagQueryType.Current) {
return {
refId: query.refId,
name,
fields: [{ name: 'value', values: [current.value.value] }],
};
}

const history = await this.getTagHistoryValues(tag.path, tag.workspace_id, range, maxDataPoints);
return {
refId: query.refId,
name: tag.properties.displayName ?? tag.path,
fields: [{ name: 'value', values: [current.value.value] }],
name,
fields: [
{ name: 'time', values: history.datetimes },
{ name: 'value', values: history.values },
],
};
}

private async getLastUpdatedTag(path: string) {
const response = await this.backendSrv.post<TagsWithValues>(this.baseUrl + '/query-tags-with-values', {
const response = await this.backendSrv.post<TagsWithValues>(this.tagUrl + '/query-tags-with-values', {
filter: `path = "${path}"`,
take: 1,
orderBy: 'TIMESTAMP',
Expand All @@ -46,18 +54,32 @@ export class TagDataSource extends DataSourceApi<TagQuery> {
return throwIfNullish(response.tagsWithValues[0], `No tags matched the path '${path}'`);
}

private shouldRunQuery(query: TagQuery): boolean {
return Boolean(query.path);
}
private async getTagHistoryValues(path: string, workspace: string, range: TimeRange, intervals?: number) {
const response = await this.backendSrv.post<TagHistoryResponse>(this.tagHistoryUrl + '/query-decimated-history', {
paths: [path],
workspace,
startTime: range.from.toISOString(),
endTime: range.to.toISOString(),
decimation: intervals ? Math.min(intervals, 1000) : 500,
});

getDefaultQuery(): Omit<TagQuery, 'refId'> {
const { type, values } = response.results[path];
return {
path: '',
datetimes: values.map(v => dateTime(v.timestamp).valueOf()),
values: values.map(v => this.convertTagValue(v.value, type)),
};
}

private convertTagValue(value: string, type: string) {
return type === 'DOUBLE' || type === 'INT' || type === 'U_INT64' ? Number(value) : value;
}

shouldRunQuery(query: TagQuery): boolean {
return Boolean(query.path);
}

async testDatasource(): Promise<TestingStatus> {
await this.backendSrv.get(this.baseUrl + '/tags-count');
await this.backendSrv.get(this.tagUrl + '/tags-count');
return { status: 'success', message: 'Data source connected and authentication successful!' };
}
}
Loading

0 comments on commit aafd782

Please sign in to comment.