From b64145685e14f22c1db8ef27a67e70d228a544a6 Mon Sep 17 00:00:00 2001 From: Tigran Vardanyan <44769443+TigranVardanyan@users.noreply.github.com> Date: Tue, 16 Jul 2024 19:12:18 +0400 Subject: [PATCH] feat(asset): add asset data source (#62) Co-authored-by: Carson Moore --- .github/CODEOWNERS | 2 + README.md | 1 + provisioning/example.yaml | 6 +- src/core/DataSourceBase.ts | 10 +- src/core/errors.test.tsx | 107 +++++++ .../data-frame => core}/errors.tsx | 6 +- src/core/types.ts | 30 +- src/core/utils.ts | 6 +- src/datasources/asset/AssetDataSource.test.ts | 262 ++++++++++++++++++ src/datasources/asset/AssetDataSource.ts | 106 +++++++ src/datasources/asset/README.md | 4 + .../AssetDataSource.test.ts.snap | 128 +++++++++ .../components/AssetQueryEditor.test.tsx | 69 +++++ .../asset/components/AssetQueryEditor.tsx | 152 ++++++++++ src/datasources/asset/constants.ts | 8 + src/datasources/asset/img/logo-ni.svg | 11 + src/datasources/asset/module.ts | 8 + src/datasources/asset/plugin.json | 15 + src/datasources/asset/query_help.md | 5 + src/datasources/asset/types.ts | 118 ++++++++ .../components/DataFrameQueryEditor.tsx | 6 +- .../components/DataFrameQueryEditorCommon.tsx | 24 +- .../DataFrameVariableQueryEditor.tsx | 9 +- src/datasources/data-frame/types.ts | 10 +- src/datasources/system/SystemDataSource.ts | 14 +- src/datasources/system/network-utils.ts | 6 +- src/datasources/system/types.ts | 16 +- src/test/fixtures.ts | 2 +- 28 files changed, 1089 insertions(+), 52 deletions(-) create mode 100644 src/core/errors.test.tsx rename src/{datasources/data-frame => core}/errors.tsx (81%) create mode 100644 src/datasources/asset/AssetDataSource.test.ts create mode 100644 src/datasources/asset/AssetDataSource.ts create mode 100644 src/datasources/asset/README.md create mode 100644 src/datasources/asset/__snapshots__/AssetDataSource.test.ts.snap create mode 100644 src/datasources/asset/components/AssetQueryEditor.test.tsx create mode 100644 src/datasources/asset/components/AssetQueryEditor.tsx create mode 100644 src/datasources/asset/constants.ts create mode 100644 src/datasources/asset/img/logo-ni.svg create mode 100644 src/datasources/asset/module.ts create mode 100644 src/datasources/asset/plugin.json create mode 100644 src/datasources/asset/query_help.md create mode 100644 src/datasources/asset/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f3b00751..186bd36f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,3 @@ * @mure @cameronwaterman + +/src/datasources/asset @CiprianAnton @kkerezsi \ No newline at end of file diff --git a/README.md b/README.md index 283bb7a8..7614ec38 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Tools](https://grafana.github.io/plugin-tools/). - [Notebooks](src/datasources/notebook/) - [Systems](src/datasources/system/) - [Tags](src/datasources/tag/) +- [Assets](src/datasources/asset/) ### Panels diff --git a/provisioning/example.yaml b/provisioning/example.yaml index 766da9d1..d5046576 100644 --- a/provisioning/example.yaml +++ b/provisioning/example.yaml @@ -32,4 +32,8 @@ datasources: - name: SystemLink Workspaces type: ni-slworkspace-datasource uid: workspace - <<: *config \ No newline at end of file + <<: *config + - name: SystemLink Assets + type: ni-slasset-datasource + uid: asset + <<: *config diff --git a/src/core/DataSourceBase.ts b/src/core/DataSourceBase.ts index 1e2c4e52..5e8374f0 100644 --- a/src/core/DataSourceBase.ts +++ b/src/core/DataSourceBase.ts @@ -7,7 +7,7 @@ import { } from '@grafana/data'; import { BackendSrv, BackendSrvRequest, TemplateSrv, isFetchError } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { Workspace } from './types'; +import { QuerySystemsResponse, QuerySystemsRequest, Workspace } from './types'; import { sleep } from './utils'; import { lastValueFrom } from 'rxjs'; @@ -21,7 +21,9 @@ export abstract class DataSourceBase extends DataSourc } abstract defaultQuery: Partial & Omit; + abstract runQuery(query: TQuery, options: DataQueryRequest): Promise; + abstract shouldRunQuery(query: TQuery): boolean; query(request: DataQueryRequest): Promise { @@ -70,4 +72,10 @@ export abstract class DataSourceBase extends DataSourc return (DataSourceBase.Workspaces = response.workspaces); } + + async getSystems(body: QuerySystemsRequest): Promise { + return await this.post( + this.instanceSettings.url + '/nisysmgmt/v1/query-systems', body + ) + } } diff --git a/src/core/errors.test.tsx b/src/core/errors.test.tsx new file mode 100644 index 00000000..0437f73a --- /dev/null +++ b/src/core/errors.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react' +import { FetchError } from '@grafana/runtime'; +import { act } from 'react-dom/test-utils'; +import { FloatingError, parseErrorMessage } from './errors'; +import { SystemLinkError } from "./types"; +import React from 'react'; +import { errorCodes } from "../datasources/data-frame/constants"; + +test('renders with error message', () => { + render() + + expect(screen.getByText('error msg')).toBeInTheDocument() +}) + +test('does not render without error message', () => { + const { container } = render() + + expect(container.innerHTML).toBeFalsy() +}) + +test('hides after timeout', () => { + jest.useFakeTimers(); + + const { container } = render() + act(() => jest.runAllTimers()) + + expect(container.innerHTML).toBeFalsy() +}) + +test('parses error message', () => { + const errorMock: Error = { + name: 'error', + message: 'error message' + } + + const result = parseErrorMessage(errorMock) + + expect(result).toBe(errorMock.message) +}) + +test('parses fetch error message', () => { + const fetchErrorMock: FetchError = { + status: 404, + data: { message: 'error message' }, + config: { url: 'URL' } + } + + const result = parseErrorMessage(fetchErrorMock as any) + + expect(result).toBe(fetchErrorMock.data.message) +}) + +test('parses fetch error status text', () => { + const fetchErrorMock: FetchError = { + status: 404, + data: {}, + statusText: 'statusText', + config: { url: 'URL' } + } + + const result = parseErrorMessage(fetchErrorMock as any) + + expect(result).toBe(fetchErrorMock.statusText) +}) + +test('parses SystemLink error code', () => { + const systemLinkError: SystemLinkError = { + error: { + name: 'name', + args: [], + code: -255130, + message: 'error message' + } + } + const fetchErrorMock: FetchError = { + status: 404, + data: systemLinkError, + statusText: 'statusText', + config: { url: 'URL' } + } + + const result = parseErrorMessage(fetchErrorMock as any) + + expect(result).toBe(errorCodes[fetchErrorMock.data.error.code] ?? fetchErrorMock.data.error.message) +}) + +test('parses SystemLink error message', () => { + const systemLinkError: SystemLinkError = { + error: { + name: 'name', + args: [], + code: 123, + message: 'error message' + } + } + const fetchErrorMock: FetchError = { + status: 404, + data: systemLinkError, + statusText: 'statusText', + config: { url: 'URL' } + } + + const result = parseErrorMessage(fetchErrorMock as any) + + expect(result).toBe(errorCodes[fetchErrorMock.data.error.code] ?? fetchErrorMock.data.error.message) + +}) diff --git a/src/datasources/data-frame/errors.tsx b/src/core/errors.tsx similarity index 81% rename from src/datasources/data-frame/errors.tsx rename to src/core/errors.tsx index c11b56c9..8a7ee52c 100644 --- a/src/datasources/data-frame/errors.tsx +++ b/src/core/errors.tsx @@ -1,9 +1,9 @@ import { isFetchError } from '@grafana/runtime'; import { Alert } from '@grafana/ui'; -import { errorCodes } from './constants'; +import { errorCodes } from '../datasources/data-frame/constants'; import React, { useState, useEffect } from 'react'; import { useTimeoutFn } from 'react-use'; -import { isSystemLinkError } from './types'; +import { isSystemLinkError } from './utils'; export const FloatingError = ({ message = '' }) => { const [hide, setHide] = useState(false); @@ -19,7 +19,7 @@ export const FloatingError = ({ message = '' }) => { return ; }; -export const parseErrorMessage = (error: Error) => { +export const parseErrorMessage = (error: Error): string | undefined => { if (isFetchError(error)) { if (isSystemLinkError(error.data)) { return errorCodes[error.data.error.code] ?? error.data.error.message; diff --git a/src/core/types.ts b/src/core/types.ts index fab0892f..d61a0458 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,8 +1,34 @@ +import { SystemMetadata } from "../datasources/system/types"; + export interface Workspace { - name: string; - id: string; + id: string, + name: string, + default: boolean, + enabled: boolean, +} + +export interface QuerySystemsRequest { + skip?: number, + take?: number, + filter?: string, + projection?: string, + orderBy?: string +} + +export interface QuerySystemsResponse { + data: SystemMetadata[] + count: number } export type DeepPartial = { [Key in keyof T]?: DeepPartial; }; + +export interface SystemLinkError { + error: { + args: string[]; + code: number; + message: string; + name: string; + } +} diff --git a/src/core/utils.ts b/src/core/utils.ts index 26aefcfa..4b18b03b 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,7 +1,7 @@ import { SelectableValue } from '@grafana/data'; import { useAsync } from 'react-use'; import { DataSourceBase } from './DataSourceBase'; -import { Workspace } from './types'; +import { SystemLinkError, Workspace } from './types'; import { TemplateSrv } from '@grafana/runtime'; export function enumToOptions(stringEnum: { [name: string]: T }): Array> { @@ -82,3 +82,7 @@ export function replaceVariables(values: string[], templateSrv: TemplateSrv) { // Dedupe and flatten return [...new Set(replaced.flat())]; } + +export function isSystemLinkError(error: any): error is SystemLinkError { + return Boolean(error?.error?.code) && Boolean(error?.error?.name); +} diff --git a/src/datasources/asset/AssetDataSource.test.ts b/src/datasources/asset/AssetDataSource.test.ts new file mode 100644 index 00000000..b9ec001b --- /dev/null +++ b/src/datasources/asset/AssetDataSource.test.ts @@ -0,0 +1,262 @@ +import { BackendSrv } from "@grafana/runtime"; +import { MockProxy } from "jest-mock-extended"; +import { + createFetchError, + createFetchResponse, + getQueryBuilder, + requestMatching, + setupDataSource, +} from "test/fixtures"; +import { AssetDataSource } from "./AssetDataSource"; +import { + AssetPresenceWithSystemConnectionModel, + AssetQuery, + AssetQueryType, + AssetsResponse, +} from "./types"; + +let ds: AssetDataSource, backendSrv: MockProxy + +beforeEach(() => { + [ds, backendSrv] = setupDataSource(AssetDataSource); +}); + +const assetsResponseMock: AssetsResponse = + { + "assets": [ + { + "modelName": "sbRIO-9629", + "modelNumber": 31299, + "serialNumber": "01FE20D1", + "vendorName": "National Instruments", + "vendorNumber": 0, + "busType": "BUILT_IN_SYSTEM", + "name": "NI-sbRIO-9629-01FE20D1", + "assetType": "SYSTEM", + "discoveryType": "AUTOMATIC", + "firmwareVersion": "8.8.0f0", + "hardwareVersion": "", + "visaResourceName": "", + "temperatureSensors": [ + { + "name": "FPGA", + "reading": 38.5625 + }, + { + "name": "Primary", + "reading": 36.75 + }, + { + "name": "Secondary", + "reading": 34.8125 + }, + { + "name": "CPU Core 2", + "reading": 43 + }, + { + "name": "CPU Core 3", + "reading": 43 + }, + { + "name": "CPU Core 0", + "reading": 43 + }, + { + "name": "CPU Core 1", + "reading": 43 + } + ], + "supportsSelfCalibration": false, + "supportsExternalCalibration": false, + "isNIAsset": true, + "id": "7f6d0d74-bc75-4d78-9edd-00c253b3a0de", + "location": { + "minionId": "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "parent": "", + "resourceUri": "system", + "slotNumber": -1, + "state": { + "assetPresence": "PRESENT" + } + }, + "calibrationStatus": "OK", + "isSystemController": true, + "workspace": "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "properties": {}, + "keywords": [], + "lastUpdatedTimestamp": "2024-02-21T12:54:20.069Z", + "fileIds": [], + "supportsSelfTest": false, + "supportsReset": false + }, + { + "modelName": "AI 0-15", + "modelNumber": 31303, + "serialNumber": "01FE20D1", + "vendorName": "National Instruments", + "vendorNumber": 4243, + "busType": "CRIO", + "name": "Conn0_AI", + "assetType": "GENERIC", + "discoveryType": "AUTOMATIC", + "firmwareVersion": "", + "hardwareVersion": "B", + "visaResourceName": "", + "temperatureSensors": [], + "supportsSelfCalibration": false, + "supportsExternalCalibration": true, + "isNIAsset": true, + "id": "71fbef61-fae6-4364-82d4-29feb79c7146", + "location": { + "minionId": "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "parent": "sbRIO1", + "resourceUri": "7/4243/31303/01FE20D1/3", + "slotNumber": 3, + "state": { + "assetPresence": "PRESENT", + "systemConnection": "DISCONNECTED" + } as AssetPresenceWithSystemConnectionModel + }, + "calibrationStatus": "OK", + "isSystemController": false, + "workspace": "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "properties": {}, + "keywords": [], + "lastUpdatedTimestamp": "2024-02-21T12:54:20.072Z", + "fileIds": [], + "supportsSelfTest": false, + "supportsReset": false + }, + { + "modelName": "AO 0-3", + "modelNumber": 31304, + "serialNumber": "01FE20D1", + "vendorName": "National Instruments", + "vendorNumber": 4243, + "busType": "CRIO", + "name": "Conn0_AO", + "assetType": "GENERIC", + "discoveryType": "AUTOMATIC", + "firmwareVersion": "", + "hardwareVersion": "B", + "visaResourceName": "", + "temperatureSensors": [], + "supportsSelfCalibration": false, + "supportsExternalCalibration": true, + "isNIAsset": true, + "id": "cf1ac843-06a2-4713-ab43-f9d1d8dfdc32", + "location": { + "minionId": "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "parent": "sbRIO1", + "resourceUri": "7/4243/31304/01FE20D1/4", + "slotNumber": 4, + "state": { + "assetPresence": "PRESENT", + "systemConnection": "DISCONNECTED" + } as AssetPresenceWithSystemConnectionModel + }, + "calibrationStatus": "OK", + "isSystemController": false, + "workspace": "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "properties": {}, + "keywords": [], + "lastUpdatedTimestamp": "2024-02-21T12:54:20.076Z", + "fileIds": [], + "supportsSelfTest": false, + "supportsReset": false + }, + { + "modelName": "DIO 0-3", + "modelNumber": 31305, + "serialNumber": "01FE20D1", + "vendorName": "National Instruments", + "vendorNumber": 4243, + "busType": "CRIO", + "name": "Conn0_DIO0-3", + "assetType": "GENERIC", + "discoveryType": "AUTOMATIC", + "firmwareVersion": "", + "hardwareVersion": "", + "visaResourceName": "", + "temperatureSensors": [], + "supportsSelfCalibration": false, + "supportsExternalCalibration": false, + "isNIAsset": true, + "id": "456e8812-1da4-4818-afab-f0cd34f74567", + "location": { + "minionId": "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "parent": "sbRIO1", + "resourceUri": "7/4243/31305/01FE20D1/5", + "slotNumber": 5, + "state": { + "assetPresence": "PRESENT", + "systemConnection": "DISCONNECTED" + } as AssetPresenceWithSystemConnectionModel + }, + "calibrationStatus": "OK", + "isSystemController": false, + "workspace": "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "properties": {}, + "keywords": [], + "lastUpdatedTimestamp": "2024-02-21T12:54:20.078Z", + "fileIds": [], + "supportsSelfTest": false, + "supportsReset": false + } + ], + "totalCount": 4 + } + +const assetUtilizationQueryMock: AssetQuery = { + type: AssetQueryType.Metadata, + workspace: '', + refId: '', + minionIds: ['123'], +} + +const buildQuery = getQueryBuilder()({ + type: AssetQueryType.Metadata, + workspace: '', + minionIds: [], +}); + +describe('testDatasource', () => { + test('returns success', async () => { + backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/assets?take=1' })) + .mockReturnValue(createFetchResponse(25)); + + const result = await ds.testDatasource(); + + expect(result.status).toEqual('success'); + }); + + test('bubbles up exception', async () => { + backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/assets?take=1' })) + .mockReturnValue(createFetchError(400)); + + await expect(ds.testDatasource()).rejects.toHaveProperty('status', 400); + }); +}) + +describe('queries', () => { + test('run metadata query', async () => { + backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/query-assets' })) + .mockReturnValue(createFetchResponse(assetsResponseMock as AssetsResponse)) + + const result = await ds.query(buildQuery(assetUtilizationQueryMock)) + + expect(result.data).toMatchSnapshot() + }) + + test('handles query error', async () => { + backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/query-assets' })) + .mockReturnValue(createFetchError(418)) + + await expect(ds.query(buildQuery(assetUtilizationQueryMock))).rejects.toThrow() + }) +}) diff --git a/src/datasources/asset/AssetDataSource.ts b/src/datasources/asset/AssetDataSource.ts new file mode 100644 index 00000000..91fbac9e --- /dev/null +++ b/src/datasources/asset/AssetDataSource.ts @@ -0,0 +1,106 @@ +import { + DataFrameDTO, + DataQueryRequest, + DataSourceInstanceSettings, + TestDataSourceResponse, +} from '@grafana/data'; +import { BackendSrv, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; +import { DataSourceBase } from 'core/DataSourceBase'; +import { + AssetFilterProperties, + AssetModel, AssetQuery, + AssetQueryType, + AssetsResponse, +} from './types'; +import { getWorkspaceName, replaceVariables } from "../../core/utils"; +import { SystemMetadata } from "../system/types"; +import { defaultOrderBy, defaultProjection } from "../system/constants"; + +export class AssetDataSource extends DataSourceBase { + constructor( + readonly instanceSettings: DataSourceInstanceSettings, + readonly backendSrv: BackendSrv = getBackendSrv(), + readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { + super(instanceSettings, backendSrv, templateSrv); + } + + baseUrl = this.instanceSettings.url + '/niapm/v1'; + + defaultQuery = { + type: AssetQueryType.Metadata, + workspace: '', + minionIds: [], + }; + + async runQuery(query: AssetQuery, options: DataQueryRequest): Promise { + return await this.processMetadataQuery(query) + } + + async processMetadataQuery(query: AssetQuery) { + const result: DataFrameDTO = { refId: query.refId, fields: [] }; + const minionIds = replaceVariables(query.minionIds, this.templateSrv); + let workspaceId = this.templateSrv.replace(query.workspace); + const conditions = []; + if (minionIds.length) { + const systemsCondition = minionIds.map(id => `${AssetFilterProperties.LocationMinionId} = "${id}"`) + conditions.push(`(${systemsCondition.join(' or ')})`); + } + if (workspaceId) { + conditions.push(`workspace = "${workspaceId}"`); + } + const assetFilter = conditions.join(' and '); + const assets: AssetModel[] = await this.queryAssets(assetFilter, 1000); + const workspaces = await this.getWorkspaces(); + result.fields = [ + { name: 'id', values: assets.map(a => a.id) }, + { name: 'name', values: assets.map(a => a.name) }, + { name: 'model name', values: assets.map(a => a.modelName) }, + { name: 'serial number', values: assets.map(a => a.serialNumber) }, + { name: 'bus type', values: assets.map(a => a.busType) }, + { name: 'asset type', values: assets.map(a => a.assetType) }, + { name: 'is NI asset', values: assets.map(a => a.isNIAsset) }, + { name: 'calibration status', values: assets.map(a => a.calibrationStatus) }, + { name: 'is system controller', values: assets.map(a => a.isSystemController) }, + { name: 'last updated timestamp', values: assets.map(a => a.lastUpdatedTimestamp) }, + { name: 'minionId', values: assets.map(a => a.location.minionId) }, + { name: 'parent name', values: assets.map(a => a.location.parent) }, + { name: 'workspace', values: assets.map(a => getWorkspaceName(workspaces, a.workspace)) }, + ]; + return result; + } + + + shouldRunQuery(_: AssetQuery): boolean { + return true; + } + + async queryAssets(filter = '', take = -1): Promise { + let data = { filter, take }; + try { + let response = await this.post(this.baseUrl + '/query-assets', data); + return response.assets; + } catch (error) { + throw new Error(`An error occurred while querying assets: ${error}`); + } + } + + async querySystems(filter = '', projection = defaultProjection): Promise { + try { + let response = await this.getSystems({ + filter: filter, + projection: `new(${projection.join()})`, + orderBy: defaultOrderBy, + }) + + return response.data; + } catch (error) { + throw new Error(`An error occurred while querying systems: ${error}`); + } + } + + async testDatasource(): Promise { + await this.get(this.baseUrl + '/assets?take=1'); + return { status: 'success', message: 'Data source connected and authentication successful!' }; + } +} diff --git a/src/datasources/asset/README.md b/src/datasources/asset/README.md new file mode 100644 index 00000000..947afe88 --- /dev/null +++ b/src/datasources/asset/README.md @@ -0,0 +1,4 @@ +# Systemlink Asset data source + +This is a plugin for the Asset Performance Management service. It allows you to: +- Visualize asset metadata on a dashboard diff --git a/src/datasources/asset/__snapshots__/AssetDataSource.test.ts.snap b/src/datasources/asset/__snapshots__/AssetDataSource.test.ts.snap new file mode 100644 index 00000000..0ecddf07 --- /dev/null +++ b/src/datasources/asset/__snapshots__/AssetDataSource.test.ts.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`queries run metadata query 1`] = ` +[ + { + "fields": [ + { + "name": "id", + "values": [ + "7f6d0d74-bc75-4d78-9edd-00c253b3a0de", + "71fbef61-fae6-4364-82d4-29feb79c7146", + "cf1ac843-06a2-4713-ab43-f9d1d8dfdc32", + "456e8812-1da4-4818-afab-f0cd34f74567", + ], + }, + { + "name": "name", + "values": [ + "NI-sbRIO-9629-01FE20D1", + "Conn0_AI", + "Conn0_AO", + "Conn0_DIO0-3", + ], + }, + { + "name": "model name", + "values": [ + "sbRIO-9629", + "AI 0-15", + "AO 0-3", + "DIO 0-3", + ], + }, + { + "name": "serial number", + "values": [ + "01FE20D1", + "01FE20D1", + "01FE20D1", + "01FE20D1", + ], + }, + { + "name": "bus type", + "values": [ + "BUILT_IN_SYSTEM", + "CRIO", + "CRIO", + "CRIO", + ], + }, + { + "name": "asset type", + "values": [ + "SYSTEM", + "GENERIC", + "GENERIC", + "GENERIC", + ], + }, + { + "name": "is NI asset", + "values": [ + true, + true, + true, + true, + ], + }, + { + "name": "calibration status", + "values": [ + "OK", + "OK", + "OK", + "OK", + ], + }, + { + "name": "is system controller", + "values": [ + true, + false, + false, + false, + ], + }, + { + "name": "last updated timestamp", + "values": [ + "2024-02-21T12:54:20.069Z", + "2024-02-21T12:54:20.072Z", + "2024-02-21T12:54:20.076Z", + "2024-02-21T12:54:20.078Z", + ], + }, + { + "name": "minionId", + "values": [ + "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + "NI_sbRIO-9629--SN-01FE20D1--MAC-00-80-2F-33-30-18", + ], + }, + { + "name": "parent name", + "values": [ + "", + "sbRIO1", + "sbRIO1", + "sbRIO1", + ], + }, + { + "name": "workspace", + "values": [ + "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "e73fcd94-649b-4d0a-b164-bf647a5d0946", + "e73fcd94-649b-4d0a-b164-bf647a5d0946", + ], + }, + ], + "refId": "A", + }, +] +`; diff --git a/src/datasources/asset/components/AssetQueryEditor.test.tsx b/src/datasources/asset/components/AssetQueryEditor.test.tsx new file mode 100644 index 00000000..aff8dd32 --- /dev/null +++ b/src/datasources/asset/components/AssetQueryEditor.test.tsx @@ -0,0 +1,69 @@ +import { AssetDataSource } from "../AssetDataSource" +import { setupRenderer } from "test/fixtures" +import { AssetQuery, AssetQueryType } from "../types" +import { screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react" +import { AssetQueryEditor } from "./AssetQueryEditor" +import { select } from "react-select-event"; +import { SystemMetadata } from "../../system/types"; + +const fakeSystems: SystemMetadata[] = [ + { + id: '1', + state: 'CONNECTED', + workspace: '1' + }, + { + id: '2', + state: 'CONNECTED', + workspace: '2' + }, +]; + +class FakeAssetDataSource extends AssetDataSource { + querySystems(filter?: string, projection?: string[]): Promise { + return Promise.resolve(fakeSystems); + } +} + +const render = setupRenderer(AssetQueryEditor, FakeAssetDataSource); +const workspacesLoaded = () => waitForElementToBeRemoved(screen.getByTestId('Spinner')); + +it('renders with query defaults', async () => { + render({} as AssetQuery) + await workspacesLoaded() + + expect(screen.getAllByRole('combobox')[0]).toHaveAccessibleDescription('Any workspace'); + expect(screen.getAllByRole('combobox')[1]).toHaveAccessibleDescription('Select systems'); +}) + +it('renders with initial query and updates when user makes changes', async () => { + const [onChange] = render({ type: AssetQueryType.Metadata, minionIds: ['1'], workspace: '2' }); + await workspacesLoaded(); + + // Renders saved query + expect(screen.getByText('Other workspace')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + + // User selects different workspace + await select(screen.getAllByRole('combobox')[0], 'Default workspace', { container: document.body }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ workspace: '1' })); + }); + + // After selecting different workspace minionIds must be empty + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ minionIds: [] })); + }); + + // User selects system + await select(screen.getAllByRole('combobox')[1], '2', { container: document.body }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ minionIds: ['2'] })); + }); + + // User adds another system + await select(screen.getAllByRole('combobox')[1], '$test_var', { container: document.body }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ minionIds: ['2', '$test_var'] })); + }); +}); diff --git a/src/datasources/asset/components/AssetQueryEditor.tsx b/src/datasources/asset/components/AssetQueryEditor.tsx new file mode 100644 index 00000000..61fdf87c --- /dev/null +++ b/src/datasources/asset/components/AssetQueryEditor.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { QueryEditorProps, SelectableValue, toOption } from '@grafana/data'; +import { InlineField } from 'core/components/InlineField'; +import { AssetDataSource } from "../AssetDataSource"; +import { + AssetQuery, + EntityType, +} from '../types'; +import { FloatingError, parseErrorMessage } from "../../../core/errors"; +import { MultiSelect, Select } from "@grafana/ui"; +import { isValidId } from "../../data-frame/utils"; +import { useWorkspaceOptions } from "../../../core/utils"; +import { SystemMetadata } from "../../system/types"; +import _ from "lodash"; +import { useAsync } from "react-use"; + +type Props = QueryEditorProps; + +export function AssetQueryEditor({ query, onChange, onRunQuery, datasource }: Props) { + query = datasource.prepareQuery(query); + const workspaces = useWorkspaceOptions(datasource); + const [errorMsg, setErrorMsg] = useState(''); + const handleError = (error: Error) => setErrorMsg(parseErrorMessage(error)); + + const minionIds = useAsync(() => { + let filterString = ''; + if (query.workspace) { + filterString += `workspace = "${query.workspace}"`; + } + return datasource.querySystems(filterString).catch(handleError); + }, [query.workspace]); + + const handleQueryChange = (value: AssetQuery, runQuery: boolean): void => { + onChange(value); + if (runQuery) { + onRunQuery(); + } + }; + + const onWorkspaceChange = (item?: SelectableValue): void => { + if (item?.value && item.value !== query.workspace) { + // if workspace changed, reset Systems and Assets fields + handleQueryChange( + { ...query, workspace: item.value, minionIds: [] }, + // do not run query if workspace not changed + true + ); + } else { + handleQueryChange({ ...query, workspace: '' }, true); + } + }; + const handleMinionIdChange = (items: Array>): void => { + if (items && !_.isEqual(query.minionIds, items)) { + handleQueryChange( + { ...query, minionIds: items.map(i => i.value!) }, + // do not run query if minionIds not changed + true + ); + } else { + handleQueryChange({ ...query, minionIds: [] }, true); + } + }; + + const getVariableOptions = (): Array> => { + return datasource.templateSrv + .getVariables() + .map((v) => toOption('$' + v.name)); + }; + const loadMinionIdOptions = (): Array> => { + let options: SelectableValue[] = (minionIds.value ?? []).map((system: SystemMetadata): SelectableValue => ({ + 'label': system.alias ?? system.id, + 'value': system.id, + 'description': system.state, + }) + ) + options.unshift(...getVariableOptions()); + + return options; + } + + return ( +
+ +