From f04e911099f89c98d01630c12afeac0809914165 Mon Sep 17 00:00:00 2001 From: Carson Moore Date: Fri, 11 Aug 2023 13:28:09 -0500 Subject: [PATCH 1/5] WIP --- src/core/utils.ts | 19 +++++++ src/datasources/tag/TagDataSource.test.ts | 3 ++ src/datasources/tag/TagDataSource.ts | 50 +++++++++++-------- .../tag/components/TagQueryEditor.test.tsx | 3 ++ src/datasources/tag/types.ts | 20 +++++++- 5 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/core/utils.ts b/src/core/utils.ts index 0adcb0a..65fd6cf 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -9,3 +9,22 @@ export function enumToOptions(stringEnum: { [name: string]: T }): Array { + if (typeof error === 'string') { + throw new Error(error); + } + throw error; +}; + +/** + * Throw exception if value is null or undefined + * @param value value to be checked for null or undefined + * @param error either error object or a string + */ +export const throwIfNullish = (value: T, error: string | Error): NonNullable => + value === undefined || value === null ? Throw(error) : value!; diff --git a/src/datasources/tag/TagDataSource.test.ts b/src/datasources/tag/TagDataSource.test.ts index 3f2e014..1a544ec 100644 --- a/src/datasources/tag/TagDataSource.test.ts +++ b/src/datasources/tag/TagDataSource.test.ts @@ -11,10 +11,13 @@ beforeEach(() => { describe('testDatasource', () => { test('returns success', async () => { + // Given - data source configured correctly backendSrv.get.calledWith('/nitag/v2/tags-count').mockResolvedValue(25); + // When - user tests connection const result = await ds.testDatasource(); + // Then - successful message expect(result.status).toEqual('success'); }); diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index 181045d..761c7af 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -3,46 +3,54 @@ import { DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, - MutableDataFrame, - FieldType + toDataFrame, } from '@grafana/data'; import { BackendSrv, TestingStatus, getBackendSrv } from '@grafana/runtime'; -import { TagQuery } from './types'; +import { TagQuery, TagsWithValues } from './types'; +import { throwIfNullish } from 'core/utils'; export class TagDataSource extends DataSourceApi { baseUrl: string; - constructor( - private instanceSettings: DataSourceInstanceSettings, - private backendSrv: BackendSrv = getBackendSrv() - ) { + constructor(private instanceSettings: DataSourceInstanceSettings, private backendSrv: BackendSrv = getBackendSrv()) { super(instanceSettings); this.baseUrl = this.instanceSettings.url + '/nitag/v2'; } async query(options: DataQueryRequest): Promise { - const { range } = options; - const from = range!.from.valueOf(); - const to = range!.to.valueOf(); - - // Return a constant for each query. - const data = options.targets.map((target) => { - return new MutableDataFrame({ - refId: target.refId, - fields: [ - { name: 'Time', values: [from, to], type: FieldType.time }, - { name: 'Value', values: [2.72, 3.14], type: FieldType.number }, - ], + const promises = options.targets.map(async (target) => { + const { tag, current } = await this.getMostRecentlyUpdatedTag(target.path); + + return toDataFrame({ + name: tag.properties.displayName ?? tag.path, + fields: [{ name: 'Value', values: [current.value.value] }], }); }); - return { data }; + return Promise.all(promises).then((data) => ({ data })); + } + + private async getMostRecentlyUpdatedTag(path: string) { + const response = await this.backendSrv.post(this.baseUrl + '/query-tags-with-values', { + filter: `path = "${path}"`, + take: 1, + orderBy: 'TIMESTAMP', + descending: true, + }); + + const tagWithValue = throwIfNullish(response.tagsWithValues[0], "❌"); + + return tagWithValue; + } + + filterQuery(query: TagQuery): boolean { + return Boolean(query.path); } getDefaultQuery(): Omit { return { - path: '' + path: '', }; } diff --git a/src/datasources/tag/components/TagQueryEditor.test.tsx b/src/datasources/tag/components/TagQueryEditor.test.tsx index 0dc242c..64333a6 100644 --- a/src/datasources/tag/components/TagQueryEditor.test.tsx +++ b/src/datasources/tag/components/TagQueryEditor.test.tsx @@ -4,10 +4,13 @@ import { TagQueryEditor } from './TagQueryEditor'; import { renderQueryEditor } from 'test/fixtures'; it('updates query with new tag path', async () => { + // Given - TagQueryEditor with empty query const [onChange] = renderQueryEditor(TagQueryEditor, { path: '' }); + // When - user types in tag path await userEvent.type(screen.getByLabelText('Tag path'), 'my.tag{enter}'); + // Then - onChange called with new tag path expect(onChange).toBeCalledWith(expect.objectContaining({ path: 'my.tag' })); }); diff --git a/src/datasources/tag/types.ts b/src/datasources/tag/types.ts index e6caf06..8786523 100644 --- a/src/datasources/tag/types.ts +++ b/src/datasources/tag/types.ts @@ -1,5 +1,23 @@ -import { DataQuery } from '@grafana/schema' +import { DataQuery } from '@grafana/schema'; export interface TagQuery extends DataQuery { path: string; } + +export interface TagWithValue { + current: { + timestamp: string; + value: { + type: string; + value: string; + }; + }; + tag: { + path: string; + properties: { displayName?: string } + } +} + +export interface TagsWithValues { + tagsWithValues: TagWithValue[]; +}; From 2547817cce4741ea20544ed1b26b36494212e2e0 Mon Sep 17 00:00:00 2001 From: Carson Moore Date: Fri, 11 Aug 2023 13:35:43 -0500 Subject: [PATCH 2/5] Redo types + rename method --- src/datasources/tag/TagDataSource.ts | 27 ++++++++++++++------------- src/datasources/tag/types.ts | 4 ---- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index 761c7af..461c68d 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -8,7 +8,7 @@ import { import { BackendSrv, TestingStatus, getBackendSrv } from '@grafana/runtime'; -import { TagQuery, TagsWithValues } from './types'; +import { TagQuery, TagWithValue } from './types'; import { throwIfNullish } from 'core/utils'; export class TagDataSource extends DataSourceApi { @@ -20,7 +20,7 @@ export class TagDataSource extends DataSourceApi { async query(options: DataQueryRequest): Promise { const promises = options.targets.map(async (target) => { - const { tag, current } = await this.getMostRecentlyUpdatedTag(target.path); + const { tag, current } = await this.getLastUpdatedTag(target.path); return toDataFrame({ name: tag.properties.displayName ?? tag.path, @@ -31,17 +31,18 @@ export class TagDataSource extends DataSourceApi { return Promise.all(promises).then((data) => ({ data })); } - private async getMostRecentlyUpdatedTag(path: string) { - const response = await this.backendSrv.post(this.baseUrl + '/query-tags-with-values', { - filter: `path = "${path}"`, - take: 1, - orderBy: 'TIMESTAMP', - descending: true, - }); - - const tagWithValue = throwIfNullish(response.tagsWithValues[0], "❌"); - - return tagWithValue; + private async getLastUpdatedTag(path: string) { + const response = await this.backendSrv.post<{ tagsWithValues: TagWithValue[] }>( + this.baseUrl + '/query-tags-with-values', + { + filter: `path = "${path}"`, + take: 1, + orderBy: 'TIMESTAMP', + descending: true, + } + ); + + return throwIfNullish(response.tagsWithValues[0], '❌'); } filterQuery(query: TagQuery): boolean { diff --git a/src/datasources/tag/types.ts b/src/datasources/tag/types.ts index 8786523..f902b0c 100644 --- a/src/datasources/tag/types.ts +++ b/src/datasources/tag/types.ts @@ -17,7 +17,3 @@ export interface TagWithValue { properties: { displayName?: string } } } - -export interface TagsWithValues { - tagsWithValues: TagWithValue[]; -}; From e2f3e9ebd92d943b8f677673a645ddced5984ebe Mon Sep 17 00:00:00 2001 From: Carson Moore Date: Fri, 11 Aug 2023 14:57:11 -0500 Subject: [PATCH 3/5] remove comments --- src/datasources/tag/components/TagQueryEditor.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/datasources/tag/components/TagQueryEditor.test.tsx b/src/datasources/tag/components/TagQueryEditor.test.tsx index 64333a6..0dc242c 100644 --- a/src/datasources/tag/components/TagQueryEditor.test.tsx +++ b/src/datasources/tag/components/TagQueryEditor.test.tsx @@ -4,13 +4,10 @@ import { TagQueryEditor } from './TagQueryEditor'; import { renderQueryEditor } from 'test/fixtures'; it('updates query with new tag path', async () => { - // Given - TagQueryEditor with empty query const [onChange] = renderQueryEditor(TagQueryEditor, { path: '' }); - // When - user types in tag path await userEvent.type(screen.getByLabelText('Tag path'), 'my.tag{enter}'); - // Then - onChange called with new tag path expect(onChange).toBeCalledWith(expect.objectContaining({ path: 'my.tag' })); }); From 2a7fef56819f02109a069d5e483bc0d2d99fb9a8 Mon Sep 17 00:00:00 2001 From: Carson Moore Date: Mon, 14 Aug 2023 12:32:50 -0500 Subject: [PATCH 4/5] Finished --- src/core/utils.ts | 7 +-- src/datasources/tag/TagDataSource.test.ts | 65 +++++++++++++++++++++-- src/datasources/tag/TagDataSource.ts | 27 +++++----- src/datasources/tag/types.ts | 16 +++--- src/test/fixtures.ts | 18 ++++++- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/src/core/utils.ts b/src/core/utils.ts index 65fd6cf..5e441af 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -14,7 +14,7 @@ export function enumToOptions(stringEnum: { [name: string]: T }): Array { +export function Throw(error: string | Error): never { if (typeof error === 'string') { throw new Error(error); } @@ -26,5 +26,6 @@ export const Throw = (error: string | Error): never => { * @param value value to be checked for null or undefined * @param error either error object or a string */ -export const throwIfNullish = (value: T, error: string | Error): NonNullable => - value === undefined || value === null ? Throw(error) : value!; +export function throwIfNullish(value: T, error: string | Error): NonNullable { + return value === undefined || value === null ? Throw(error) : value!; +} diff --git a/src/datasources/tag/TagDataSource.test.ts b/src/datasources/tag/TagDataSource.test.ts index 1a544ec..d1ac4d2 100644 --- a/src/datasources/tag/TagDataSource.test.ts +++ b/src/datasources/tag/TagDataSource.test.ts @@ -1,7 +1,8 @@ import { MockProxy } from 'jest-mock-extended'; import { TagDataSource } from './TagDataSource'; -import { createDataSource, createFetchError } from 'test/fixtures'; +import { createQueryRequest, createDataSource, createFetchError } from 'test/fixtures'; import { BackendSrv } from '@grafana/runtime'; +import { TagsWithValues } from './types'; let ds: TagDataSource, backendSrv: MockProxy; @@ -11,13 +12,10 @@ beforeEach(() => { describe('testDatasource', () => { test('returns success', async () => { - // Given - data source configured correctly backendSrv.get.calledWith('/nitag/v2/tags-count').mockResolvedValue(25); - // When - user tests connection const result = await ds.testDatasource(); - // Then - successful message expect(result.status).toEqual('success'); }); @@ -27,3 +25,62 @@ describe('testDatasource', () => { await expect(ds.testDatasource()).rejects.toHaveProperty('status', 400); }); }); + +describe('queries', () => { + test('tag current value', async () => { + backendSrv.post + .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' })); + + expect(result.data).toEqual([ + { + fields: [{ name: 'value', values: ['3.14'] }], + name: 'my.tag', + refId: 'A', + }, + ]); + }); + + 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' })); + + expect(result.data[0]).toEqual(expect.objectContaining({ name: 'My cool tag' })); + }); + + test('multiple targets - skips invalid queries', async () => { + backendSrv.post + .mockResolvedValueOnce(createQueryTagsResponse('my.tag1', '3.14')) + .mockResolvedValueOnce(createQueryTagsResponse('my.tag2', 'foo')); + + const result = await ds.query(createQueryRequest({ path: 'my.tag1' }, { path: '' }, { 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"'); + expect(result.data).toEqual([ + { + fields: [{ name: 'value', values: ['3.14'] }], + name: 'my.tag1', + refId: 'A', + }, + { + fields: [{ name: 'value', values: ['foo'] }], + name: 'my.tag2', + refId: 'C', + }, + ]); + }); + + test('throw when no tags matched', async () => { + backendSrv.post.mockResolvedValue({ tagsWithValues: [] }); + + await expect(ds.query(createQueryRequest({ path: 'my.tag' }))).rejects.toThrow('my.tag'); + }); +}); + +function createQueryTagsResponse(path: string, value: string, displayName?: string): TagsWithValues { + return { tagsWithValues: [{ current: { value: { value } }, tag: { path, properties: { displayName } } }] }; +} diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index 461c68d..4bf78a7 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -1,14 +1,14 @@ import { + DataFrameDTO, DataQueryRequest, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, - toDataFrame, } from '@grafana/data'; import { BackendSrv, TestingStatus, getBackendSrv } from '@grafana/runtime'; -import { TagQuery, TagWithValue } from './types'; +import { TagQuery, TagsWithValues } from './types'; import { throwIfNullish } from 'core/utils'; export class TagDataSource extends DataSourceApi { @@ -19,20 +19,21 @@ export class TagDataSource extends DataSourceApi { } async query(options: DataQueryRequest): Promise { - const promises = options.targets.map(async (target) => { - const { tag, current } = await this.getLastUpdatedTag(target.path); + return { data: await Promise.all(options.targets.filter(this.shouldRunQuery).map(this.runQuery, this)) }; + } - return toDataFrame({ - name: tag.properties.displayName ?? tag.path, - fields: [{ name: 'Value', values: [current.value.value] }], - }); - }); + private async runQuery(query: TagQuery): Promise { + const { tag, current } = await this.getLastUpdatedTag(query.path); - return Promise.all(promises).then((data) => ({ data })); + return { + refId: query.refId, + name: tag.properties.displayName ?? tag.path, + fields: [{ name: 'value', values: [current.value.value] }], + }; } private async getLastUpdatedTag(path: string) { - const response = await this.backendSrv.post<{ tagsWithValues: TagWithValue[] }>( + const response = await this.backendSrv.post( this.baseUrl + '/query-tags-with-values', { filter: `path = "${path}"`, @@ -42,10 +43,10 @@ export class TagDataSource extends DataSourceApi { } ); - return throwIfNullish(response.tagsWithValues[0], '❌'); + return throwIfNullish(response.tagsWithValues[0], `No tags matched the path '${path}'`); } - filterQuery(query: TagQuery): boolean { + private shouldRunQuery(query: TagQuery): boolean { return Boolean(query.path); } diff --git a/src/datasources/tag/types.ts b/src/datasources/tag/types.ts index f902b0c..dc3a32d 100644 --- a/src/datasources/tag/types.ts +++ b/src/datasources/tag/types.ts @@ -5,15 +5,13 @@ export interface TagQuery extends DataQuery { } export interface TagWithValue { - current: { - timestamp: string; - value: { - type: string; - value: string; - }; - }; + current: { value: { value: string } }; tag: { path: string; - properties: { displayName?: string } - } + properties: { displayName?: string }; + }; +} + +export interface TagsWithValues { + tagsWithValues: TagWithValue[]; } diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 1868e40..6238a53 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -1,4 +1,4 @@ -import { DataSourceApi, DataSourceInstanceSettings, QueryEditorProps } from '@grafana/data'; +import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, QueryEditorProps, dateTime } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { BackendSrv, FetchError } from '@grafana/runtime'; import { mock } from 'jest-mock-extended'; @@ -42,3 +42,19 @@ export function renderQueryEditor, TQuery e export function createFetchError(status: number): FetchError { return mock({ status }); } + +export function createQueryRequest( + ...targets: Array> +): DataQueryRequest { + return { + targets: targets.map((t, ix) => ({...t, refId: 'ABCDE'[ix]} as TQuery)), + requestId: '', + interval: '', + intervalMs: 0, + range: { from: dateTime().subtract(1, 'h'), to: dateTime(), raw: { from: 'now-6h', to: 'now' } }, + scopedVars: {}, + timezone: 'browser', + app: 'panel-editor', + startTime: 0, + }; +} From f68f382b850770a2349ae60855e1275b2b97a16b Mon Sep 17 00:00:00 2001 From: Carson Moore Date: Mon, 14 Aug 2023 12:35:33 -0500 Subject: [PATCH 5/5] Linting fixes --- src/datasources/tag/TagDataSource.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/datasources/tag/TagDataSource.ts b/src/datasources/tag/TagDataSource.ts index 4bf78a7..b556b7b 100644 --- a/src/datasources/tag/TagDataSource.ts +++ b/src/datasources/tag/TagDataSource.ts @@ -13,7 +13,10 @@ import { throwIfNullish } from 'core/utils'; export class TagDataSource extends DataSourceApi { baseUrl: string; - constructor(private instanceSettings: DataSourceInstanceSettings, private backendSrv: BackendSrv = getBackendSrv()) { + constructor( + private instanceSettings: DataSourceInstanceSettings, + private backendSrv: BackendSrv = getBackendSrv() + ) { super(instanceSettings); this.baseUrl = this.instanceSettings.url + '/nitag/v2'; } @@ -33,15 +36,12 @@ export class TagDataSource extends DataSourceApi { } private async getLastUpdatedTag(path: string) { - const response = await this.backendSrv.post( - this.baseUrl + '/query-tags-with-values', - { - filter: `path = "${path}"`, - take: 1, - orderBy: 'TIMESTAMP', - descending: true, - } - ); + const response = await this.backendSrv.post(this.baseUrl + '/query-tags-with-values', { + filter: `path = "${path}"`, + take: 1, + orderBy: 'TIMESTAMP', + descending: true, + }); return throwIfNullish(response.tagsWithValues[0], `No tags matched the path '${path}'`); }