diff --git a/src/core/errors.test.tsx b/src/core/errors.test.tsx new file mode 100644 index 0000000..b5eb42a --- /dev/null +++ b/src/core/errors.test.tsx @@ -0,0 +1,105 @@ +import { render } from '@testing-library/react' +import { FetchError } from '@grafana/runtime'; +import { act } from 'react-dom/test-utils'; +import { FloatingError, parseErrorMessage } from './errors'; +import { SystemLinkError } from '../datasources/asset/types'; +import React from 'react'; + +test('renders with error message', () => { + const { container } = render() + + expect(container.innerHTML).toBeTruthy() // refact: get by text +}) + +test('does not render without error message', () => { + const { container } = render() + + expect(container.innerHTML).toBeFalsy() // refact: get by text +}) + +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(fetchErrorMock.statusText) +}) + +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(fetchErrorMock.statusText) +}) 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 c11b56c..8a7ee52 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/utils.ts b/src/core/utils.ts index 26aefcf..b50dc47 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -82,3 +82,16 @@ export function replaceVariables(values: string[], templateSrv: TemplateSrv) { // Dedupe and flatten return [...new Set(replaced.flat())]; } + +export interface SystemLinkError { + error: { + args: string[]; + code: number; + message: string; + name: string; + } +} + +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 0000000..135339e --- /dev/null +++ b/src/datasources/asset/AssetDataSource.test.ts @@ -0,0 +1,115 @@ +import { BackendSrv } from "@grafana/runtime"; +import { MockProxy } from "jest-mock-extended"; +import { + createFetchError, + createFetchResponse, + getQueryBuilder, + mockTimers, + peakDaysMock, + requestMatching, + setupDataSource, + assetModelMock, +} from "test/fixtures"; +import { AssetDataSource } from "./AssetDataSource"; +import { + AssetsResponse, + AssetQueryType, + AssetQuery, + EntityType, + IsNIAsset, + IsPeak, + UtilizationCategory, + TimeFrequency, PolicyOption +} from "./types"; +import { dateTime } from "@grafana/data"; + + +let ds: AssetDataSource, backendSrv: MockProxy + +beforeEach(() => { + [ds, backendSrv] = setupDataSource(AssetDataSource); +}); + +const assetUtilizationQueryMock: AssetQuery = { + assetQueryType: AssetQueryType.METADATA, + workspace: '', + entityType: EntityType.ASSET, + isPeak: IsPeak.NONPEAK, + peakDays: peakDaysMock, + refId: '', + utilizationCategory: UtilizationCategory.TEST, + assetIdentifier: '321', + isNIAsset: IsNIAsset.NIASSET, + minionId: '123', + timeFrequency: TimeFrequency.DAILY, + peakStart: dateTime(new Date(2024, 1, 1, 9, 0)), + nonPeakStart: dateTime(new Date(2024, 1, 1, 17, 0)), + policyOption: PolicyOption.DEFAULT +} + +const dataFrameDTOMock = [ + { name: 'model name', values: [''] }, + { name: 'serial number', values: [''] }, + { name: 'bus type', values: ['USB'] }, + { name: 'asset type', values: ['DEVICE_UNDER_TEST'] }, + { name: 'is NI asset', values: [true] }, + { + name: 'calibration status', + values: ['APPROACHING_RECOMMENDED_DUE_DATE'] + }, + { name: 'is system controller', values: [true] }, + { name: 'workspace', values: [''] }, + { name: 'last updated timestamp', values: [''] }, + { name: 'minionId', values: ['minion1'] }, + { name: 'parent name', values: [''] }, + { name: 'system name', values: ['system1'] }, + { + name: 'calibration due date', + values: ['2019-05-07T18:58:05.000Z'] + } +] + +const buildQuery = getQueryBuilder()({}); + +mockTimers(); + +describe('testDatasource', () => { + test('returns success', async () => { + backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/assets' })) + .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' })) + .mockReturnValue(createFetchError(400)); + + await expect(ds.testDatasource()).rejects.toHaveProperty('status', 400); + }); +}) + +describe('queries', () => { + test('runs metadata query', async () => { + const queryAssets = backendSrv.fetch + .calledWith(requestMatching({ url: '/niapm/v1/query-assets' })) + .mockReturnValue(createFetchResponse({ assets: assetModelMock, totalCount: 0 } as AssetsResponse)) + + const result = await ds.query(buildQuery(assetUtilizationQueryMock)) + + expect(result.data[0].fields).toEqual(expect.arrayContaining(dataFrameDTOMock)) + expect(queryAssets).toHaveBeenCalledTimes(1) + }) + + 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 0000000..178ed2c --- /dev/null +++ b/src/datasources/asset/AssetDataSource.ts @@ -0,0 +1,325 @@ +import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, dateTime, MetricFindValue, TestDataSourceResponse, } from '@grafana/data'; +import { BackendSrv, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; +import { DataSourceBase } from 'core/DataSourceBase'; +import { + AssetFilterProperties, + AssetModel, AssetQuery, + AssetQueryType, + AssetsResponse, + AssetUtilizationHistory, + AssetUtilizationHistoryResponse, + AssetUtilizationOrderBy, + AssetVariableQuery, + EntityType, + Interval, + IsNIAsset, + IsPeak, + PolicyOption, + QueryAssetUtilizationHistoryRequest, + ServicePolicyModel, + TimeFrequency, + UtilizationCategory, + Weekday +} from './types'; +import { + calculateUtilization, + divideTimeRangeToBusinessIntervals, + extractTimestampsFromData, + filterDataByTimeRange, + groupDataByIntervals, + mergeOverlappingIntervals, + momentToTime, + patchMissingEndTimestamps, + patchZeroPoints +} from "./helper"; +import { replaceVariables } from "../../core/utils"; +import { SystemMetadata } from "../system/types"; +import { defaultOrderBy, defaultProjection } from "../system/constants"; +import { isPeakLabels, timeFrequencyLabels } from "./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'; + sysmgmtUrl = this.instanceSettings.url + '/nisysmgmt/v1' + + defaultQuery = { + assetQueryType: AssetQueryType.METADATA, + workspace: '', + entityType: EntityType.ASSET, + assetIdentifier: '', + isNIAsset: IsNIAsset.BOTH, + minionId: '', + isPeak: IsPeak.PEAK, + timeFrequency: TimeFrequency.DAILY, + utilizationCategory: UtilizationCategory.ALL, + peakDays: [Weekday.Monday, Weekday.Tuesday, Weekday.Wednesday, Weekday.Thursday, Weekday.Friday], + policyOption: PolicyOption.DEFAULT, + peakStart: undefined, + nonPeakStart: undefined + }; + + async runQuery(query: AssetQuery, options: DataQueryRequest): Promise { + const result: DataFrameDTO = { refId: query.refId, fields: [] }; + + if (query.assetQueryType === AssetQueryType.METADATA) { + let workspace_id = this.templateSrv.replace(query.workspace); + let minion_id = this.templateSrv.replace(query.minionId); + const conditions = []; + if (workspace_id) { + conditions.push(`workspace = "${workspace_id}"`); + } + if (minion_id) { + conditions.push(`${AssetFilterProperties.LocationMinionId} = "${minion_id}"`); + } + const assetFilter = conditions.join(' and '); + let assetResponse: AssetModel[] = await this.queryAssets(assetFilter); + result.fields = [ + { name: 'id', values: assetResponse.map(a => a.id) }, + { name: 'name', values: assetResponse.map(a => a.name) }, + { name: 'model name', values: assetResponse.map(a => a.modelName) }, + { name: 'serial number', values: assetResponse.map(a => a.serialNumber) }, + { name: 'bus type', values: assetResponse.map(a => a.busType) }, + { name: 'asset type', values: assetResponse.map(a => a.assetType) }, + { name: 'is NI asset', values: assetResponse.map(a => a.isNIAsset) }, + { name: 'calibration status', values: assetResponse.map(a => a.calibrationStatus) }, + { name: 'is system controller', values: assetResponse.map(a => a.isSystemController) }, + { name: 'workspace', values: assetResponse.map(a => a.workspace) }, + { name: 'last updated timestamp', values: assetResponse.map(a => a.lastUpdatedTimestamp) }, + { name: 'minionId', values: assetResponse.map(a => a.location.minionId) }, + { name: 'parent name', values: assetResponse.map(a => a.location.parent) }, + { name: 'system name', values: assetResponse.map(a => a.location.systemName) }, + { + name: 'calibration due date', + values: assetResponse.map(a => a.externalCalibration?.nextRecommendedDate) + }, + ]; + return result; + } else { + let workspace_id = this.templateSrv.replace(query.workspace); + // fetch and process utilization raw data for chosen assets/systems + const utilization_array = await this.fetchAndProcessUtilizationData(query, options); + let utilization_array_with_alias: Array<{ id: string, datetimes: number[], values: number[], alias: string }>; + const idsArray = utilization_array.map((data) => { + return data.id + }); + // find and add aliases to data + if (query.entityType === EntityType.SYSTEM) { + const filterArr: string[] = []; + idsArray.forEach((id) => { + filterArr.push(`id == "${id}" `); + }); + const filterStr = workspace_id ? `(${filterArr.join(' or ')}) and workspace = "${workspace_id}"` : filterArr.join(' or '); + const systems = await this.querySystems(filterStr, defaultProjection); + utilization_array_with_alias = utilization_array.map(system_utilization_df => { + const foundItem = systems.find((system: SystemMetadata) => system.id === system_utilization_df.id); + return { ...system_utilization_df, alias: foundItem ? foundItem.alias : 'System' }; + }); + } else { + const filterArr: string[] = []; + idsArray.forEach((id) => { + filterArr.push(`${AssetFilterProperties.AssetIdentifier} == "${id}"`); + }); + const filterStr = workspace_id ? ` (${filterArr.join(' or ')}) and workspace = "${workspace_id}"` : filterArr.join(' or '); + const assetsResponse = await this.queryAssets(filterStr, -1); + utilization_array_with_alias = utilization_array.map(asset_utilization_df => { + const foundItem = assetsResponse.find((asset: AssetModel) => asset.id === asset_utilization_df.id); + return { ...asset_utilization_df, alias: foundItem ? foundItem.name : 'Asset' }; + }); + } + const suffix = `${isPeakLabels[query.isPeak]} ${timeFrequencyLabels[query.timeFrequency]}`; + result.fields = [ + { name: 'time', values: utilization_array_with_alias[0].datetimes }, + ]; + utilization_array_with_alias.forEach((data) => { + result.fields.push( + { + name: data.id, + values: data.values, + // default configuration for visualizations + config: { + displayName: `${data.alias} ${suffix}`, + unit: '%', + min: 0, + max: 100 + } + } + ) + }) + return result; + } + } + + shouldRunQuery(_: AssetQuery): boolean { + return true; + } + + private async fetchAndProcessUtilizationData(query: AssetQuery, options: DataQueryRequest): Promise> { + const [from, to] = [options.range.from.valueOf(), options.range.to.valueOf()]; + const workspace_id = this.templateSrv.replace(query.workspace); + const minion_id = this.templateSrv.replace(query.minionId); + // this value is set in the Datasource -> Query options -> Max data points + const maxDataPoints = options.maxDataPoints; + let continuationToken = ''; + const { + assetIdentifier, + minionId, + utilizationCategory, + isPeak, + peakDays, + timeFrequency + } = query; + const workingHoursPolicy = { + 'startTime': momentToTime(dateTime(query.peakStart)), + 'endTime': momentToTime(dateTime(query.nonPeakStart)) + } + const utilizationFilter: string = utilizationCategory === UtilizationCategory.TEST ? `Category = \"Test\" or Category = \"test\"` : ''; + let arrayOfEntityIds: string[]; + if (query.entityType === EntityType.ASSET) { + arrayOfEntityIds = replaceVariables([assetIdentifier], this.templateSrv); + } else { + arrayOfEntityIds = replaceVariables([minionId], this.templateSrv); + } + // filter empty values + arrayOfEntityIds = arrayOfEntityIds.filter(Boolean); + const resultArray: Array<{ + id: string, + datetimes: number[], + values: number[] + }> = []; + if (!arrayOfEntityIds.length) { + if (query.entityType === EntityType.ASSET) { + let conditions: string[] = []; + if (workspace_id) { + conditions.push(`workspace = "${workspace_id}"`); + } + if (minion_id) { + conditions.push(`${AssetFilterProperties.LocationMinionId} = "${minion_id}"`); + } + const assetFilter = conditions.join(' and '); + const assetsResponse = await this.queryAssets(assetFilter, 25); + arrayOfEntityIds = assetsResponse.map((asset) => { + return asset.id; + }) + } else { + let filterString = ''; + if (query.workspace) { + filterString += `workspace = "${query.workspace}"`; + } + let systemMetadata = await this.post<{ data: SystemMetadata[] }>(this.sysmgmtUrl + '/query-systems', { + filter: filterString, + projection: `new(${defaultProjection.join()})`, + orderBy: defaultOrderBy, + take: 10 + }); + arrayOfEntityIds = systemMetadata.data.map((system) => { + return system.id; + }) + } + } + for (let index = 0; index < arrayOfEntityIds.length; index++) { + let data: AssetUtilizationHistory[] = []; + let requestCount = 0; + do { + try { + let requestBody: QueryAssetUtilizationHistoryRequest = { + "utilizationFilter": utilizationFilter, + "continuationToken": continuationToken, + "orderBy": AssetUtilizationOrderBy.START_TIMESTAMP, + "orderByDescending": false + } + if (query.entityType === EntityType.ASSET) { + requestBody.assetFilter = `${AssetFilterProperties.AssetIdentifier} = "${arrayOfEntityIds[index]}"`; + } else { + requestBody.assetFilter = `${AssetFilterProperties.LocationMinionId} = "${arrayOfEntityIds[index]}" and ${AssetFilterProperties.IsSystemController} = true`; + } + const response: AssetUtilizationHistoryResponse = await this.post(this.baseUrl + '/query-asset-utilization-history', requestBody); + requestCount++; + continuationToken = response.continuationToken || ''; + data = data.concat(response.assetUtilizations); + } catch (error) { + throw new Error(`An error occurred while retrieving utilization history: ${error}`); + } + } while (continuationToken && (maxDataPoints && requestCount < maxDataPoints)); + + // let utilization_data: { id: string, datetimes: Date, values: number[] }; + let dataWithoutOverlaps: Array> = []; + if (data.length) { + const extractedTimestamps = extractTimestampsFromData(data); + // Fill missing endTimestamps + const patchedData = patchMissingEndTimestamps(extractedTimestamps); + // Removes data outside the grafana 'from' and 'to' range from an array + const filteredData = filterDataByTimeRange(patchedData, from, to); + // Merge overlapping utilizations + dataWithoutOverlaps = mergeOverlappingIntervals(filteredData); + } + // Divide given time range to peak/non-peak intervals + const businessIntervals = divideTimeRangeToBusinessIntervals(new Date(from), new Date(to), workingHoursPolicy, peakDays, timeFrequency); + // Group raw utilization data by intervals + const overlaps = groupDataByIntervals(businessIntervals, dataWithoutOverlaps, peakDays, isPeak); + // Calculate utilization percentage by intervals + const utilization = calculateUtilization(overlaps); + // patch zero points to data for better visualisation + const patchedUtilization = patchZeroPoints(utilization, new Date(from), new Date(to), timeFrequency); + + let utilization_data: { id: string, datetimes: number[], values: number[] } = + { + id: arrayOfEntityIds[index], + datetimes: patchedUtilization.map(v => dateTime(v.day).valueOf()), + values: patchedUtilization.map(v => v.utilization) + } + resultArray.push(utilization_data); + } + + return resultArray; + } + + async getServicePolicy(): Promise { + return await this.get(this.baseUrl + '/policy'); + } + + 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.post<{ data: SystemMetadata[] }>(this.sysmgmtUrl + '/query-systems', { + 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'); + return { status: 'success', message: 'Data source connected and authentication successful!' }; + } + + async metricFindQuery({ minionId }: AssetVariableQuery): Promise { + const minionID = this.templateSrv.replace(minionId); + const filter = minionID ? `Location.MinionId = \"${minionID}\"` : ''; + const assetsResponse: AssetModel[] = await this.queryAssets(filter); + return assetsResponse.map((asset: AssetModel) => ({ text: asset.name, value: asset.id })); + } +} diff --git a/src/datasources/asset/README.md b/src/datasources/asset/README.md new file mode 100644 index 0000000..0374483 --- /dev/null +++ b/src/datasources/asset/README.md @@ -0,0 +1,8 @@ +# Systemlink Asset data source + +This is a plugin for the Asset Performance Management. It allows you to: +- Populate a + [query variable](https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#add-a-query-variable) + with a list of assets connected to the server +- Visualize asset metadata on a dashboard +- Visualize asset utilization on a dashboard diff --git a/src/datasources/asset/components/AssetQueryEditor.test.tsx b/src/datasources/asset/components/AssetQueryEditor.test.tsx new file mode 100644 index 0000000..737f9f8 --- /dev/null +++ b/src/datasources/asset/components/AssetQueryEditor.test.tsx @@ -0,0 +1,36 @@ +import { AssetDataSource } from "../AssetDataSource" +import { setupRenderer } from "test/fixtures" +import { + AssetQueryType, + AssetQuery, defaultQuery, + EntityType, +} from "../types" +import { screen, waitForElementToBeRemoved } from "@testing-library/react" +import { AssetQueryEditor } from "./AssetQueryEditor" +import userEvent from "@testing-library/user-event"; + +const render = setupRenderer(AssetQueryEditor, AssetDataSource); + +const workspacesLoaded = () => waitForElementToBeRemoved(screen.getByTestId('Spinner')); + +test('renders with query defaults', async () => { + render({} as AssetQuery) + await workspacesLoaded() + + expect(screen.getByRole('radio', { name: 'Metadata' })).toBeChecked(); +}) + +it('updates when user interacts with fields', async () => { + const [onChange] = render(defaultQuery as AssetQuery); + await workspacesLoaded() + + expect(screen.getByRole('radio', { name: 'Metadata' })).toBeChecked(); + + // User changes query type + await userEvent.click(screen.getByRole('radio', { name: "Utilization" })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ assetQueryType: AssetQueryType.UTILIZATION })); + expect(screen.getByRole('radio', { name: 'Asset' })).toBeChecked(); + await userEvent.click(screen.getByRole('radio', { name: "System" })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ entityType: EntityType.SYSTEM })); + expect(screen.queryByText('Asset Identifier')).not.toBeInTheDocument(); +}); diff --git a/src/datasources/asset/components/AssetQueryEditor.tsx b/src/datasources/asset/components/AssetQueryEditor.tsx new file mode 100644 index 0000000..05bbf70 --- /dev/null +++ b/src/datasources/asset/components/AssetQueryEditor.tsx @@ -0,0 +1,370 @@ +import React, { useState } from 'react'; +import { QueryEditorProps, SelectableValue, toOption, dateTime } from '@grafana/data'; +import { InlineField } from 'core/components/InlineField'; +import { AssetDataSource } from "../AssetDataSource"; +import { + AssetFilterProperties, + AssetQueryType, + AssetQuery, + EntityType, + IsNIAsset, + IsPeak, + PolicyOption, + TimeFrequency, + UtilizationCategory, + Weekday, AssetModel +} from '../types'; +import { FloatingError, parseErrorMessage } from "../../../core/errors"; +import { useAsync } from "react-use"; +import { arraysEqual, } from "../helper"; +import { HorizontalGroup, MultiSelect, RadioButtonGroup, Select, TimeOfDayPicker } from "@grafana/ui"; +import { isValidId } from "../../data-frame/utils"; +import { + assetQueryTypeOptions, + entityTypeOptions, + isNIAssetOptions, + isPeakOptions, + peakDayOptions, + policyOptions, + timeFrequencyOptions, + utilizationCategoryOptions +} from "../constants"; +import { useWorkspaceOptions } from "../../../core/utils"; +import { SystemMetadata } from "../../system/types"; + +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 assetIds = useAsync(() => { + const filterArray: string[] = []; + if (query.isNIAsset === IsNIAsset.NIASSET) { + filterArray.push(`${AssetFilterProperties.IsNIAsset} = "true"`); + } else if (query.isNIAsset === IsNIAsset.NOTNIASSET) { + filterArray.push(`${AssetFilterProperties.IsNIAsset} = "false"`); + } + if (query.minionId) { + const resolvedId = datasource.templateSrv.replace(query.minionId); + filterArray.push(`${AssetFilterProperties.LocationMinionId} = "${resolvedId}"`); + } + if (query.workspace) { + filterArray.push(`workspace = "${query.workspace}"`); + } + let filter = filterArray.filter(Boolean).join(' and '); + return datasource.queryAssets(filter).catch(handleError); + }, [query.workspace, query.minionId, query.isNIAsset]); + const servicePolicy = useAsync(() => { + return datasource.getServicePolicy(); + }); + + const handleQueryChange = (value: AssetQuery, runQuery: boolean): void => { + onChange(value); + if (runQuery) { + onRunQuery(); + } + }; + + const onWorkspaceChange = (item?: SelectableValue): void => { + if (item?.value && query.workspace !== item.value) { + const { assetIdentifier, minionId, ...changedQuery } = query; + handleQueryChange({ ...changedQuery, workspace: item.value, minionId: '', assetIdentifier: '' }, true); + } else { + handleQueryChange({ ...query, workspace: '' }, true); + } + }; + const handleMinionIdChange = (item: SelectableValue): void => { + if (item?.value && query.minionId !== item.value) { + const { assetIdentifier, ...changedQuery } = query; + handleQueryChange({ ...changedQuery, minionId: item.value, assetIdentifier: '' }, true); + } else { + handleQueryChange({ ...query, minionId: '' }, true); + } + }; + const handleAssetIdentifierChange = (item: SelectableValue): void => { + if (item?.value && query.assetIdentifier !== item.value) { + handleQueryChange({ ...query, assetIdentifier: item.value }, true); + } else { + handleQueryChange({ ...query, assetIdentifier: '' }, true); + } + }; + const handleIsNIAssetChange = (value: IsNIAsset): void => { + if (query.isNIAsset !== value) { + handleQueryChange({ ...query, isNIAsset: value }, false); + } + }; + const handleAssetQueryTypeChange = (value: AssetQueryType): void => { + if (query.assetQueryType !== value) { + handleQueryChange({ ...query, assetQueryType: value }, true); + } + }; + const handleEntityTypeChange = (value: EntityType): void => { + if (query.entityType !== value) { + handleQueryChange({ ...query, entityType: value }, true); + } + }; + const handleUtilizationTimeFrequencyChange = (item: TimeFrequency): void => { + if (query.timeFrequency !== item) { + handleQueryChange({ ...query, timeFrequency: item }, true); + } + }; + const handleUtilizationCategoryChange = (value: UtilizationCategory): void => { + if (query.utilizationCategory !== value) { + handleQueryChange({ ...query, utilizationCategory: value }, true); + } + }; + const handlePeakDaysChange = (items: Array>): void => { + if (!arraysEqual(items, query.peakDays)) { + handleQueryChange({ ...query, peakDays: items.map(item => item.value!) }, true); + } + }; + const handleIsPeakChange = async (item: IsPeak): Promise => { + if (query.isPeak !== item) { + handleQueryChange({ ...query, isPeak: item }, true); + } + }; + const handlePolicyOptionChange = async (item: PolicyOption): Promise => { + if (query.policyOption !== item) { + if (item === PolicyOption.ALL) { + const startDate = new Date((new Date()).setHours(0, 0, 0)); + const endDate = new Date((new Date()).setHours(0, 0, 0)); + handleQueryChange({ + ...query, + policyOption: item, + peakStart: dateTime(startDate), + nonPeakStart: dateTime(endDate) + }, true); + } else if (item === PolicyOption.DEFAULT) { + const workingHoursPolicy = (await datasource.getServicePolicy()).workingHoursPolicy; + const [startHours, startMinutes, startSeconds] = workingHoursPolicy.startTime.split(':').map(Number); + const [endHours, endMinutes, endSeconds] = workingHoursPolicy.endTime.split(':').map(Number); + const startDate = new Date((new Date()).setHours(startHours, startMinutes, startSeconds)); + const endDate = new Date((new Date()).setHours(endHours, endMinutes, endSeconds)); + handleQueryChange({ + ...query, + policyOption: item, + peakStart: dateTime(startDate), + nonPeakStart: dateTime(endDate), + }, true); + } else { + handleQueryChange({ ...query, policyOption: item }, true); + } + } + }; + const handlePeakStartChange = (time: any): void => { + handleQueryChange({ ...query, peakStart: dateTime(time.toISOString()) }, true); + }; + const handleNonPeakStartChange = (time: any): void => { + handleQueryChange({ ...query, nonPeakStart: dateTime(time.toISOString()) }, 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, + 'value': system.id, + 'description': system.state, + }) + ) + options.unshift(...getVariableOptions()); + + return options; + } + + const loadAssetIdOptions = (): Array> => { + let options: SelectableValue[] = (assetIds.value ?? []).map((asset: AssetModel): SelectableValue => ({ + 'label': asset.name, + 'value': asset.id, + description: asset.id + }) + ); + options.unshift(...getVariableOptions()); + + return options; + } + + return ( +
+ + handleAssetQueryTypeChange(item)} + /> + + {query.assetQueryType === AssetQueryType.METADATA && + (<> + + + + )} + { + query.assetQueryType === AssetQueryType.UTILIZATION && + (<> + + handleEntityTypeChange(item)} + id={'ss'} + /> + + + + + + handleIsNIAssetChange(item)} + /> + + {query.entityType === EntityType.ASSET && ( + +