From fde403a3795be6ffd12085e16c61633c4fbd0ccd Mon Sep 17 00:00:00 2001 From: Arnaud Van der Poorten Date: Mon, 25 Nov 2024 16:52:14 +0100 Subject: [PATCH] Check validity of regexes before sending them to Historian backend --- .gitignore | 1 + .vscode/settings.json | 2 +- Makefile | 4 +- package.json | 2 +- src/CustomVariableEditor/AssetFilter.tsx | 50 ++++++++++++------- src/CustomVariableEditor/DatabaseFilter.tsx | 22 ++++---- src/CustomVariableEditor/EventTypeFilter.tsx | 22 ++++---- .../MeasurementFilter.tsx | 38 ++++++++------ src/CustomVariableEditor/VariableEditor.tsx | 20 +++++--- src/components/util/MaybeRegexInput.tsx | 43 ++++++++++++++++ src/components/util/MeasurementSelect.tsx | 27 +++++++++- src/datasource.ts | 35 +++++++++++-- src/types.ts | 4 ++ src/util/util.ts | 36 +++++++++++++ src/variable_support.ts | 34 +++++++++++-- 15 files changed, 267 insertions(+), 73 deletions(-) create mode 100644 src/components/util/MaybeRegexInput.tsx create mode 100644 src/util/util.ts diff --git a/.gitignore b/.gitignore index 0a1df74..5705c53 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ node-modules/ factry-historian-datasource/ *.zip *.sha1 +.eslintcache diff --git a/.vscode/settings.json b/.vscode/settings.json index 72446f4..25fa621 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/Makefile b/Makefile index 0780cdd..fe0ec23 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ major = 2 -minor = 0 -patch = 4 +minor = 1 +patch = 1 prerelease = project_name=factry-historian-datasource diff --git a/package.json b/package.json index 93114f5..69a2cc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "factry-historian-datasource", - "version": "2.1.0", + "version": "2.1.1", "description": "A datasource plugin for Factry Historian", "scripts": { "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", diff --git a/src/CustomVariableEditor/AssetFilter.tsx b/src/CustomVariableEditor/AssetFilter.tsx index 22f1eac..7f919f0 100644 --- a/src/CustomVariableEditor/AssetFilter.tsx +++ b/src/CustomVariableEditor/AssetFilter.tsx @@ -1,30 +1,39 @@ -import React, { ChangeEvent, FormEvent, useState } from 'react' +import React, { ChangeEvent, useState } from 'react' -import { AsyncMultiSelect, InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui' +import { AsyncMultiSelect, InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui' import { DataSource } from 'datasource' import { AssetFilter } from 'types' import { SelectableValue } from '@grafana/data' +import { MaybeRegexInput } from 'components/util/MaybeRegexInput' export function AssetFilterRow(props: { datasource: DataSource - onChange: (val: AssetFilter) => void + onChange: (val: AssetFilter, valid: boolean) => void initialValue?: AssetFilter templateVariables: Array> }) { const [parentAssets, setParentAssets] = useState>>() + const [pathValid, setPathValid] = useState(false) - const onPathChange = (event: FormEvent) => { - props.onChange({ - ...props.initialValue, - Path: (event as ChangeEvent).target.value, - }) + const onPathChange = (value: string, valid: boolean) => { + setPathValid(valid) + props.onChange( + { + ...props.initialValue, + Path: value, + }, + valid + ) } const onUseAssetPathChange = (event: ChangeEvent): void => { - props.onChange({ - ...props.initialValue, - UseAssetPath: event.target.checked, - }) + props.onChange( + { + ...props.initialValue, + UseAssetPath: event.target.checked, + }, + pathValid + ) } const loadAssetOptions = async (query: string): Promise>> => { @@ -43,14 +52,19 @@ export function AssetFilterRow(props: { if (!parentAssets) { setParentAssets(selectableValues.filter((e) => props.initialValue?.ParentUUIDs?.includes(e.value ?? ''))) } - return [{ label: 'No parent', value: '00000000-0000-0000-0000-000000000000' } as SelectableValue].concat(selectableValues) + return [{ label: 'No parent', value: '00000000-0000-0000-0000-000000000000' } as SelectableValue].concat( + selectableValues + ) } const onParentAssetsChange = (values: Array>) => { - props.onChange({ - ...props.initialValue, - ParentUUIDs: values.map((e) => e.value ?? ''), - }) + props.onChange( + { + ...props.initialValue, + ParentUUIDs: values.map((e) => e.value ?? ''), + }, + pathValid + ) setParentAssets(values) } @@ -63,7 +77,7 @@ export function AssetFilterRow(props: { labelWidth={20} tooltip={
Searches asset by path, to use a regex surround pattern with /
} > - + diff --git a/src/CustomVariableEditor/DatabaseFilter.tsx b/src/CustomVariableEditor/DatabaseFilter.tsx index 2e9fe7a..1ed93d7 100644 --- a/src/CustomVariableEditor/DatabaseFilter.tsx +++ b/src/CustomVariableEditor/DatabaseFilter.tsx @@ -1,19 +1,23 @@ -import React, { ChangeEvent, FormEvent } from 'react' +import React from 'react' -import { InlineField, InlineFieldRow, Input } from '@grafana/ui' +import { InlineField, InlineFieldRow } from '@grafana/ui' import { DataSource } from 'datasource' import { TimeseriesDatabaseFilter } from 'types' +import { MaybeRegexInput } from 'components/util/MaybeRegexInput' export function DatabaseFilterRow(props: { datasource: DataSource - onChange: (val: TimeseriesDatabaseFilter) => void + onChange: (val: TimeseriesDatabaseFilter, valid: boolean) => void initialValue?: TimeseriesDatabaseFilter }) { - const onKeywordChange = (event: FormEvent) => { - props.onChange({ - ...props.initialValue, - Keyword: (event as ChangeEvent).target.value, - }) + const onKeywordChange = (value: string, valid: boolean) => { + props.onChange( + { + ...props.initialValue, + Keyword: value, + }, + valid + ) } return ( <> @@ -24,7 +28,7 @@ export function DatabaseFilterRow(props: { labelWidth={20} tooltip={
Searches database by name, to use a regex surround pattern with /
} > - +
diff --git a/src/CustomVariableEditor/EventTypeFilter.tsx b/src/CustomVariableEditor/EventTypeFilter.tsx index 1b38d47..f467914 100644 --- a/src/CustomVariableEditor/EventTypeFilter.tsx +++ b/src/CustomVariableEditor/EventTypeFilter.tsx @@ -1,19 +1,23 @@ -import React, { ChangeEvent, FormEvent } from 'react' +import React from 'react' -import { InlineField, InlineFieldRow, Input } from '@grafana/ui' +import { InlineField, InlineFieldRow } from '@grafana/ui' import { DataSource } from 'datasource' import { EventTypeFilter } from 'types' +import { MaybeRegexInput } from 'components/util/MaybeRegexInput' export function EventTypeFilterRow(props: { datasource: DataSource - onChange: (val: EventTypeFilter) => void + onChange: (val: EventTypeFilter, valid: boolean) => void initialValue?: EventTypeFilter }) { - const onKeywordChange = (event: FormEvent) => { - props.onChange({ - ...props.initialValue, - Keyword: (event as ChangeEvent).target.value, - }) + const onKeywordChange = (value: string, valid: boolean) => { + props.onChange( + { + ...props.initialValue, + Keyword: value, + }, + valid + ) } return ( <> @@ -24,7 +28,7 @@ export function EventTypeFilterRow(props: { labelWidth={20} tooltip={
Searches database by name, to use a regex surround pattern with /
} > - + diff --git a/src/CustomVariableEditor/MeasurementFilter.tsx b/src/CustomVariableEditor/MeasurementFilter.tsx index 3cd5cc2..f868c7f 100644 --- a/src/CustomVariableEditor/MeasurementFilter.tsx +++ b/src/CustomVariableEditor/MeasurementFilter.tsx @@ -1,14 +1,15 @@ -import React, { ChangeEvent, FormEvent, useState } from 'react' +import React, { useState } from 'react' import { SelectableValue } from '@grafana/data' -import { InlineField, InlineFieldRow, Input } from '@grafana/ui' +import { InlineField, InlineFieldRow } from '@grafana/ui' import { DataSource } from 'datasource' import { DatabaseSelect } from 'components/util/DatabaseSelect' import { MeasurementFilter } from 'types' +import { MaybeRegexInput } from 'components/util/MaybeRegexInput' export interface MeasurementFilterProps { datasource: DataSource - onChange: (val: MeasurementFilter) => void + onChange: (val: MeasurementFilter, filterValid: boolean) => void initialValue?: MeasurementFilter templateVariables: Array> } @@ -16,18 +17,24 @@ export interface MeasurementFilterProps { export function MeasurementFilterRow(props: MeasurementFilterProps) { const [selectedDatabases, setSelectedDatabases] = useState>>() - const onKeywordChange = (event: FormEvent) => { - props.onChange({ - ...props.initialValue, - Keyword: (event as ChangeEvent).target.value, - }) + const onKeywordChange = (keyword: string, valid: boolean) => { + props.onChange( + { + ...props.initialValue, + Keyword: keyword, + }, + valid + ) } const onDatabaseChange = (values: string[]) => { - props.onChange({ - ...props.initialValue, - DatabaseUUIDs: values, - }) + props.onChange( + { + ...props.initialValue, + DatabaseUUIDs: values, + }, + true + ) } return ( @@ -37,9 +44,12 @@ export function MeasurementFilterRow(props: MeasurementFilterProps) { label={'Filter measurement'} aria-label={'Filter measurement'} labelWidth={20} - tooltip={
Searches measurement by name
} + tooltip={
Searches measurement by name, to use a regex surround pattern with /
} > - onKeywordChange(e)} /> + onKeywordChange(value, ok)} + initialValue={props.initialValue?.Keyword} + /> diff --git a/src/CustomVariableEditor/VariableEditor.tsx b/src/CustomVariableEditor/VariableEditor.tsx index a88ae55..af66d83 100644 --- a/src/CustomVariableEditor/VariableEditor.tsx +++ b/src/CustomVariableEditor/VariableEditor.tsx @@ -79,6 +79,7 @@ export function VariableQueryEditor( props.onChange({ ...props.query, type: value.value!, + valid: false, filter: {}, }) } @@ -86,6 +87,7 @@ export function VariableQueryEditor( props.onChange({ ...props.query, type: value.value!, + valid: false, filter: {}, }) } @@ -93,6 +95,7 @@ export function VariableQueryEditor( props.onChange({ ...props.query, type: value.value!, + valid: false, filter: {}, }) } @@ -100,6 +103,7 @@ export function VariableQueryEditor( props.onChange({ ...props.query, type: value.value!, + valid: false, filter: {}, }) } @@ -144,9 +148,9 @@ export function VariableQueryEditor( datasource={props.datasource} initialValue={props.query.filter} templateVariables={templateVariables} - onChange={(val) => { + onChange={(val, valid) => { if (props.query.type === VariableQueryType.MeasurementQuery) { - props.onChange({ ...props.query, filter: val }) + props.onChange({ ...props.query, filter: val, valid: valid }) } }} /> @@ -156,9 +160,9 @@ export function VariableQueryEditor( datasource={props.datasource} initialValue={props.query.filter} templateVariables={templateVariables} - onChange={(val) => { + onChange={(val, valid) => { if (props.query.type === VariableQueryType.AssetQuery) { - props.onChange({ ...props.query, filter: val }) + props.onChange({ ...props.query, filter: val, valid: valid }) } }} /> @@ -179,9 +183,9 @@ export function VariableQueryEditor( { + onChange={(val, valid) => { if (props.query.type === VariableQueryType.DatabaseQuery) { - props.onChange({ ...props.query, filter: val }) + props.onChange({ ...props.query, filter: val, valid: valid }) } }} /> @@ -190,9 +194,9 @@ export function VariableQueryEditor( { + onChange={(val, valid) => { if (props.query.type === VariableQueryType.EventTypeQuery) { - props.onChange({ ...props.query, filter: val }) + props.onChange({ ...props.query, filter: val, valid: valid }) } }} /> diff --git a/src/components/util/MaybeRegexInput.tsx b/src/components/util/MaybeRegexInput.tsx new file mode 100644 index 0000000..0f55dab --- /dev/null +++ b/src/components/util/MaybeRegexInput.tsx @@ -0,0 +1,43 @@ +import React, { ChangeEvent, FormEvent, useState } from 'react' +import { Input, Tooltip } from '@grafana/ui' +import { isRegex, isValidRegex } from 'util/util' + +export interface MaybeRegexInputProps { + onChange: (val: string, valid: boolean) => void + initialValue?: string + placeHolder?: string +} + +export function MaybeRegexInput(props: MaybeRegexInputProps) { + const [error, setError] = useState() + + const onChange = (event: FormEvent) => { + const keyword = (event as ChangeEvent).target.value as string + let valid = false + if (isRegex(keyword)) { + if (isValidRegex(keyword)) { + setError(undefined) + valid = true + } else { + setError('Invalid regex') + } + } else { + setError(undefined) + valid = true + } + + props.onChange(keyword, valid) + } + + return ( + <>{error}} + theme="error" + placement="right" + show={error !== undefined} + interactive={false} + > + onChange(e)} /> + + ) +} diff --git a/src/components/util/MeasurementSelect.tsx b/src/components/util/MeasurementSelect.tsx index b1fb565..3bbc012 100644 --- a/src/components/util/MeasurementSelect.tsx +++ b/src/components/util/MeasurementSelect.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent } from 'react' +import React, { ChangeEvent, useState } from 'react' import { SelectableValue } from '@grafana/data' import { AsyncMultiSelect, @@ -8,11 +8,13 @@ import { InlineField, InlineFieldRow, Input, + Tooltip, VerticalGroup, } from '@grafana/ui' import { DataSource } from 'datasource' import { measurementToSelectableValue, useDebounce } from 'QueryEditor/util' import { Measurement, MeasurementFilter, MeasurementQuery, TimeseriesDatabase, labelWidth } from 'types' +import { isRegex, isValidRegex } from 'util/util' export interface Props { query: MeasurementQuery @@ -28,6 +30,7 @@ export interface Props { export const MeasurementSelect = (props: Props): React.JSX.Element => { const [regex, setRegex] = useDebounce(props.query.Regex ?? '', 500, props.onChangeRegex) + const [regexError, setRegexError] = useState() const getSelectedMeasurements = ( query: MeasurementQuery, @@ -84,6 +87,17 @@ export const MeasurementSelect = (props: Props): React.JSX.Element => { const onChangeRegex = (event: ChangeEvent) => { const regex = event.target.value + const keyword = '/' + regex + '/' + if (isRegex(keyword)) { + if (isValidRegex(keyword)) { + setRegexError(undefined) + } else { + setRegexError('Invalid regex') + } + } else { + setRegexError(undefined) + } + setRegex(regex) } @@ -124,7 +138,16 @@ export const MeasurementSelect = (props: Props): React.JSX.Element => { ) : ( - + // Can't use MaybeRegexInput here because of the IsRegex toggle + <>{regexError}} + theme="error" + placement="right" + show={regexError !== undefined} + interactive={false} + > + + )} diff --git a/src/datasource.ts b/src/datasource.ts index fbcdca4..860c5e4 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -31,6 +31,7 @@ import { TimeseriesDatabase, TimeseriesDatabaseFilter, } from './types' +import { isRegex, isValidRegex } from 'util/util' export class DataSource extends DataSourceWithBackend { defaultTab: TabIndex @@ -211,8 +212,13 @@ export class DataSource extends DataSourceWithBackend { + const f = this.templateReplaceMeasurementFilter(filter) + if (f.Keyword && isRegex(f.Keyword) && !isValidRegex(f.Keyword)) { + return Promise.resolve([]) + } + const params: Record = { - ...this.templateReplaceMeasurementFilter(filter), + ...f, } if (pagination.Limit) { params['limit'] = pagination.Limit @@ -230,8 +236,8 @@ export class DataSource extends DataSourceWithBackend { let params: Record = {} if (filter) { - params = { - ...filter, + if (filter.Keyword && isRegex(filter.Keyword) && !isValidRegex(filter.Keyword)) { + return [] } } return this.getResource('databases', params) @@ -255,6 +261,13 @@ export class DataSource extends DataSourceWithBackend { let params: Record = {} if (filter) { + if (filter.Keyword && isRegex(filter.Keyword) && !isValidRegex(filter.Keyword)) { + return [] + } + params = { ...filter, } @@ -309,8 +326,12 @@ export class DataSource extends DataSourceWithBackend { + const f = this.templateReplaceMeasurementFilter(filter) + if (f.Keyword && isRegex(f.Keyword) && !isValidRegex(f.Keyword)) { + return Promise.resolve([]) + } const params: Record = { - ...this.templateReplaceMeasurementFilter(filter), + ...f, } return this.getResource(`tag-keys`, params) } @@ -320,8 +341,12 @@ export class DataSource extends DataSourceWithBackend { + const f = this.templateReplaceMeasurementFilter(filter) + if (f.Keyword && isRegex(f.Keyword) && !isValidRegex(f.Keyword)) { + return Promise.resolve([]) + } const params: Record = { - ...this.templateReplaceMeasurementFilter(filter), + ...f, } return this.getResource(`tag-values/${key}`, params) } diff --git a/src/types.ts b/src/types.ts index 1dc3200..870e440 100644 --- a/src/types.ts +++ b/src/types.ts @@ -292,6 +292,7 @@ export type MeasurementVariableQuery = { refId: string type: VariableQueryType.MeasurementQuery filter?: MeasurementFilter + valid: boolean pagination?: Pagination } @@ -299,18 +300,21 @@ export type AssetVariableQuery = { refId: string type: VariableQueryType.AssetQuery filter?: AssetFilter + valid: boolean } export type EventTypeVariableQuery = { refId: string type: VariableQueryType.EventTypeQuery filter?: EventTypeFilter + valid: boolean } export type DatabaseVariableQuery = { refId: string type: VariableQueryType.DatabaseQuery filter?: TimeseriesDatabaseFilter + valid: boolean } export type EventTypePropertyVariableQuery = { diff --git a/src/util/util.ts b/src/util/util.ts new file mode 100644 index 0000000..243acf7 --- /dev/null +++ b/src/util/util.ts @@ -0,0 +1,36 @@ +/** + * Checks if a given string is formatted as a regular expression. + * + * A string is considered a regular expression if it starts and ends with a '/' character. + * + * @param str - The string to check. + * @returns `true` if the string is formatted as a regular expression, `false` otherwise. + */ +export function isRegex(str: string): boolean { + return str.length >= 2 && str.startsWith('/') && str.endsWith('/') +} + +/** + * Checks if a given string is a valid regular expression. + * + * This function first checks if the string is a valid regex pattern by calling `isRegex`. + * If it is, it attempts to create a new `RegExp` object from the string (excluding the first and last characters). + * If the creation of the `RegExp` object succeeds, the function returns `true`. + * If an error is thrown during the creation of the `RegExp` object, the function returns `false`. + * If the string is not a valid regex pattern, the function returns `false`. + * + * @param str - The string to be checked. + * @returns `true` if the string is a valid regular expression, `false` otherwise. + */ +export function isValidRegex(str: string): boolean { + if (isRegex(str)) { + try { + new RegExp(str.slice(1, -1)) + return true + } catch (e) { + return false + } + } + + return false +} diff --git a/src/variable_support.ts b/src/variable_support.ts index ac38710..6da370d 100644 --- a/src/variable_support.ts +++ b/src/variable_support.ts @@ -51,13 +51,21 @@ export class VariableSupport extends CustomVariableSupport { query(request: DataQueryRequest): Observable { const queryType = request.targets[0].type + + // If the query is not valid, return an empty array + if ('valid' in request.targets[0] && !request.targets[0].valid) { + return of({ data: [] }) + } + switch (queryType) { case VariableQueryType.MeasurementQuery: { const filter = { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as MeasurementFilter | undefined), ScopedVars: request.scopedVars, } - if (!filter) { + + // Don't allow empty filter to not query too much data + if (!filter || !filter.Keyword) { return of({ data: [] }) } @@ -95,6 +103,11 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as AssetFilter | undefined), ScopedVars: request.scopedVars, } + + // Don't allow empty filter to not query too much data + if (!filter || (!filter.Keyword && !filter.Path)) { + return of({ data: [] }) + } const useAssetPath = filter.UseAssetPath ?? false return from(this.dataAPI.getAssets(filter)).pipe( map((values) => { @@ -112,7 +125,9 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as EventTypeFilter | undefined), ScopedVars: request.scopedVars, } - + if (!filter) { + return of({ data: [] }) + } return from(this.dataAPI.getEventTypes(filter)).pipe( map((values) => { return { data: values.map((v) => ({ text: v.Name, value: v.UUID })) } @@ -124,7 +139,9 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as TimeseriesDatabaseFilter | undefined), ScopedVars: request.scopedVars, } - + if (!filter) { + return of({ data: [] }) + } return from(this.dataAPI.getTimeseriesDatabases(filter)).pipe( map((values) => { return { data: values.map((v) => ({ text: v.Name, value: v.UUID })) } @@ -136,7 +153,9 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as EventTypePropertiesFilter | undefined), ScopedVars: request.scopedVars, } - + if (!filter) { + return of({ data: [] }) + } return forkJoin({ eventTypes: this.dataAPI.getEventTypes(), eventTypeProperties: this.dataAPI.getEventTypeProperties(filter), @@ -160,6 +179,9 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as AssetPropertyFilter | undefined), ScopedVars: request.scopedVars, } + if (!filter) { + return of({ data: [] }) + } if (filter.AssetUUIDs) { filter.AssetUUIDs = filter.AssetUUIDs.flatMap((e) => this.dataAPI.multiSelectReplace(e, request.scopedVars)) } @@ -174,6 +196,10 @@ export class VariableSupport extends CustomVariableSupport { ...(JSON.parse(JSON.stringify(request.targets[0].filter)) as EventTypePropertiesValuesFilter | undefined), ScopedVars: request.scopedVars, } + if (!filter) { + return of({ data: [] }) + } + if (filter.EventTypePropertyUUID) { filter.EventTypePropertyUUID = this.dataAPI.multiSelectReplace( filter.EventTypePropertyUUID,