diff --git a/.changeset/tough-pugs-matter.md b/.changeset/tough-pugs-matter.md new file mode 100644 index 0000000..918919c --- /dev/null +++ b/.changeset/tough-pugs-matter.md @@ -0,0 +1,5 @@ +--- +'grafana-zabbix': major +--- + +Migrates use of DatasourceApi to DatasourceWithBackend diff --git a/src/datasource/components/AnnotationQueryEditor.tsx b/src/datasource/components/AnnotationQueryEditor.tsx index 7f923cb..d7c6c4a 100644 --- a/src/datasource/components/AnnotationQueryEditor.tsx +++ b/src/datasource/components/AnnotationQueryEditor.tsx @@ -9,6 +9,7 @@ import { QueryEditorRow } from './QueryEditor/QueryEditorRow'; import { MetricPicker } from '../../components'; import { getVariableOptions } from './QueryEditor/utils'; import { prepareAnnotation } from '../migrations'; +import { useInterpolatedQuery } from '../hooks/useInterpolatedQuery'; const severityOptions: Array> = [ { value: 0, label: 'Not classified' }, @@ -27,6 +28,7 @@ type Props = ZabbixQueryEditorProps & { export const AnnotationQueryEditor = ({ annotation, onAnnotationChange, datasource }: Props) => { annotation = prepareAnnotation(annotation); const query = annotation.target; + const interpolatedQuery = useInterpolatedQuery(datasource, query); const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); @@ -44,8 +46,7 @@ export const AnnotationQueryEditor = ({ annotation, onAnnotationChange, datasour }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -57,14 +58,12 @@ export const AnnotationQueryEditor = ({ annotation, onAnnotationChange, datasour }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadAppOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const apps = await datasource.zabbix.getAllApps(groupFilter, hostFilter); + const apps = await datasource.zabbix.getAllApps(group, host); let options: Array> = apps?.map((app) => ({ value: app.name, label: app.name, @@ -75,13 +74,13 @@ export const AnnotationQueryEditor = ({ annotation, onAnnotationChange, datasour }; const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => { - const options = await loadAppOptions(query.group.filter, query.host.filter); + const options = await loadAppOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/components/ConfigEditor.test.tsx b/src/datasource/components/ConfigEditor.test.tsx index e9ac9a4..bdb7bfc 100644 --- a/src/datasource/components/ConfigEditor.test.tsx +++ b/src/datasource/components/ConfigEditor.test.tsx @@ -7,7 +7,22 @@ jest.mock('@grafana/runtime', () => ({ config: {}, })); +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + config: {}, +})); + describe('ConfigEditor', () => { + beforeAll(() => { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: () => ({ + measureText: () => ({ width: 0 }), + font: '', + textAlign: '', + }), + }); + }); + describe('on initial render', () => { it('should not mutate the options object', () => { const options = Object.freeze({ ...getDefaultOptions() }); // freezing the options to prevent mutations @@ -27,7 +42,7 @@ describe('ConfigEditor', () => { const onOptionsChangeSpy = jest.fn(); expect(() => render()).not.toThrow(); - expect(onOptionsChangeSpy).toBeCalledTimes(1); + expect(onOptionsChangeSpy).toHaveBeenCalledTimes(1); expect(onOptionsChangeSpy).toHaveBeenCalledWith({ ...getDefaultOptions(), jsonData: { @@ -51,7 +66,7 @@ describe('ConfigEditor', () => { const onOptionsChangeSpy = jest.fn(); expect(() => render()).not.toThrow(); - expect(onOptionsChangeSpy).toBeCalledTimes(1); + expect(onOptionsChangeSpy).toHaveBeenCalledTimes(1); expect(onOptionsChangeSpy).toHaveBeenCalledWith({ ...getDefaultOptions(), jsonData: { diff --git a/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx b/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx index dd9fd51..09b8914 100644 --- a/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx @@ -11,6 +11,7 @@ import { ZabbixDatasource } from '../../datasource'; import { ZabbixMetricsQuery } from '../../types/query'; import { ZBXItem, ZBXItemTag } from '../../types'; import { itemTagToString } from '../../utils'; +import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; export interface Props { query: ZabbixMetricsQuery; @@ -19,6 +20,8 @@ export interface Props { } export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { + const interpolatedQuery = useInterpolatedQuery(datasource, query); + const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); const options = groups?.map((group) => ({ @@ -35,8 +38,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -48,14 +50,12 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadAppOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const apps = await datasource.zabbix.getAllApps(groupFilter, hostFilter); + const apps = await datasource.zabbix.getAllApps(group, host); let options: Array> = apps?.map((app) => ({ value: app.name, label: app.name, @@ -66,9 +66,9 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => { - const options = await loadAppOptions(query.group.filter, query.host.filter); + const options = await loadAppOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadTagOptions = async (group: string, host: string) => { const tagsAvailable = await datasource.zabbix.isZabbix54OrHigher(); @@ -76,9 +76,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { return []; } - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const items = await datasource.zabbix.getAllItems(groupFilter, hostFilter, null, null, {}); + const items = await datasource.zabbix.getAllItems(group, host, null, null, {}); const tags: ZBXItemTag[] = _.flatten(items.map((item: ZBXItem) => item.tags || [])); // const tags: ZBXItemTag[] = await datasource.zabbix.getItemTags(groupFilter, hostFilter, null); @@ -93,20 +91,16 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: tagsLoading, value: tagOptions }, fetchTags] = useAsyncFn(async () => { - const options = await loadTagOptions(query.group.filter, query.host.filter); + const options = await loadTagOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadItemOptions = async (group: string, host: string, app: string, itemTag: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const appFilter = datasource.replaceTemplateVars(app); - const tagFilter = datasource.replaceTemplateVars(itemTag); const options = { itemtype: 'num', showDisabledItems: query.options.showDisabledItems, }; - const items = await datasource.zabbix.getAllItems(groupFilter, hostFilter, appFilter, tagFilter, options); + const items = await datasource.zabbix.getAllItems(group, host, app, itemTag, options); let itemOptions: Array> = items?.map((item) => ({ value: item.name, label: item.name, @@ -118,19 +112,24 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => { const [{ loading: itemsLoading, value: itemOptions }, fetchItems] = useAsyncFn(async () => { const options = await loadItemOptions( - query.group.filter, - query.host.filter, - query.application.filter, - query.itemTag.filter + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter ); return options; - }, [query.group.filter, query.host.filter, query.application.filter, query.itemTag.filter]); + }, [ + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter, + ]); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); - const appFilter = datasource.replaceTemplateVars(query.application?.filter); - const tagFilter = datasource.replaceTemplateVars(query.itemTag?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; + const appFilter = interpolatedQuery.application?.filter; + const tagFilter = interpolatedQuery.itemTag?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/components/QueryEditor/ProblemsQueryEditor.tsx b/src/datasource/components/QueryEditor/ProblemsQueryEditor.tsx index e9f2e3b..a8f98cd 100644 --- a/src/datasource/components/QueryEditor/ProblemsQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/ProblemsQueryEditor.tsx @@ -9,6 +9,7 @@ import { MetricPicker } from '../../../components'; import { getVariableOptions } from './utils'; import { ZabbixDatasource } from '../../datasource'; import { ZabbixMetricsQuery, ZabbixTagEvalType } from '../../types/query'; +import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; const showProblemsOptions: Array> = [ { label: 'Problems', value: 'problems' }, @@ -37,6 +38,8 @@ export interface Props { } export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => { + const interpolatedQuery = useInterpolatedQuery(datasource, query); + const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); const options = groups?.map((group) => ({ @@ -53,8 +56,7 @@ export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => { }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -66,14 +68,12 @@ export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadAppOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const apps = await datasource.zabbix.getAllApps(groupFilter, hostFilter); + const apps = await datasource.zabbix.getAllApps(group, host); let options: Array> = apps?.map((app) => ({ value: app.name, label: app.name, @@ -84,9 +84,9 @@ export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => { - const options = await loadAppOptions(query.group.filter, query.host.filter); + const options = await loadAppOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadProxyOptions = async () => { const proxies = await datasource.zabbix.getProxies(); @@ -104,8 +104,8 @@ export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => { }, []); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/components/QueryEditor/TextMetricsQueryEditor.tsx b/src/datasource/components/QueryEditor/TextMetricsQueryEditor.tsx index 2574427..37d1b88 100644 --- a/src/datasource/components/QueryEditor/TextMetricsQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/TextMetricsQueryEditor.tsx @@ -9,6 +9,7 @@ import { MetricPicker } from '../../../components'; import { getVariableOptions } from './utils'; import { ZabbixDatasource } from '../../datasource'; import { ZabbixMetricsQuery } from '../../types/query'; +import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; export interface Props { query: ZabbixMetricsQuery; @@ -17,6 +18,8 @@ export interface Props { } export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) => { + const interpolatedQuery = useInterpolatedQuery(datasource, query); + const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); const options = groups?.map((group) => ({ @@ -33,8 +36,7 @@ export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) = }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -46,14 +48,12 @@ export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) = }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadAppOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const apps = await datasource.zabbix.getAllApps(groupFilter, hostFilter); + const apps = await datasource.zabbix.getAllApps(group, host); let options: Array> = apps?.map((app) => ({ value: app.name, label: app.name, @@ -64,20 +64,16 @@ export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) = }; const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => { - const options = await loadAppOptions(query.group.filter, query.host.filter); + const options = await loadAppOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadItemOptions = async (group: string, host: string, app: string, itemTag: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const appFilter = datasource.replaceTemplateVars(app); - const tagFilter = datasource.replaceTemplateVars(itemTag); const options = { itemtype: 'text', showDisabledItems: query.options.showDisabledItems, }; - const items = await datasource.zabbix.getAllItems(groupFilter, hostFilter, appFilter, tagFilter, options); + const items = await datasource.zabbix.getAllItems(group, host, app, itemTag, options); let itemOptions: Array> = items?.map((item) => ({ value: item.name, label: item.name, @@ -89,19 +85,24 @@ export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) = const [{ loading: itemsLoading, value: itemOptions }, fetchItems] = useAsyncFn(async () => { const options = await loadItemOptions( - query.group.filter, - query.host.filter, - query.application.filter, - query.itemTag.filter + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter ); return options; - }, [query.group.filter, query.host.filter, query.application.filter, query.itemTag.filter]); + }, [ + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter, + ]); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); - const appFilter = datasource.replaceTemplateVars(query.application?.filter); - const tagFilter = datasource.replaceTemplateVars(query.itemTag?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; + const appFilter = interpolatedQuery.application?.filter; + const tagFilter = interpolatedQuery.itemTag?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/components/QueryEditor/TriggersQueryEditor.tsx b/src/datasource/components/QueryEditor/TriggersQueryEditor.tsx index 1c001ef..e0993aa 100644 --- a/src/datasource/components/QueryEditor/TriggersQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/TriggersQueryEditor.tsx @@ -11,6 +11,7 @@ import { itemTagToString } from '../../utils'; import { ZabbixDatasource } from '../../datasource'; import { ZabbixMetricsQuery } from '../../types/query'; import { ZBXItem, ZBXItemTag } from '../../types'; +import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; const countByOptions: Array> = [ { value: '', label: 'All triggers' }, @@ -34,6 +35,8 @@ export interface Props { } export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { + const interpolatedQuery = useInterpolatedQuery(datasource, query); + const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); const options = groups?.map((group) => ({ @@ -50,8 +53,7 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -63,14 +65,12 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadAppOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const apps = await datasource.zabbix.getAllApps(groupFilter, hostFilter); + const apps = await datasource.zabbix.getAllApps(group, host); let options: Array> = apps?.map((app) => ({ value: app.name, label: app.name, @@ -81,19 +81,16 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => { - const options = await loadAppOptions(query.group.filter, query.host.filter); + const options = await loadAppOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadTagOptions = async (group: string, host: string) => { const tagsAvailable = await datasource.zabbix.isZabbix54OrHigher(); if (!tagsAvailable) { return []; } - - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const items = await datasource.zabbix.getAllItems(groupFilter, hostFilter, null, null, {}); + const items = await datasource.zabbix.getAllItems(group, host, null, null, {}); const tags: ZBXItemTag[] = _.flatten(items.map((item: ZBXItem) => item.tags || [])); const tagList = _.uniqBy(tags, (t) => t.tag + t.value || '').map((t) => itemTagToString(t)); @@ -107,9 +104,9 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { }; const [{ loading: tagsLoading, value: tagOptions }, fetchItemTags] = useAsyncFn(async () => { - const options = await loadTagOptions(query.group.filter, query.host.filter); + const options = await loadTagOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); const loadProxyOptions = async () => { const proxies = await datasource.zabbix.getProxies(); @@ -127,15 +124,11 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { }, []); const loadItemOptions = async (group: string, host: string, app: string, itemTag: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const appFilter = datasource.replaceTemplateVars(app); - const tagFilter = datasource.replaceTemplateVars(itemTag); const options = { itemtype: 'num', showDisabledItems: query.options.showDisabledItems, }; - const items = await datasource.zabbix.getAllItems(groupFilter, hostFilter, appFilter, tagFilter, options); + const items = await datasource.zabbix.getAllItems(group, host, app, itemTag, options); let itemOptions: Array> = items?.map((item) => ({ value: item.name, label: item.name, @@ -147,19 +140,24 @@ export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => { const [{ loading: itemsLoading, value: itemOptions }, fetchItems] = useAsyncFn(async () => { const options = await loadItemOptions( - query.group.filter, - query.host.filter, - query.application.filter, - query.itemTag.filter + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter ); return options; - }, [query.group.filter, query.host.filter, query.application.filter, query.itemTag.filter]); + }, [ + interpolatedQuery.group.filter, + interpolatedQuery.host.filter, + interpolatedQuery.application.filter, + interpolatedQuery.itemTag.filter, + ]); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); - const appFilter = datasource.replaceTemplateVars(query.application?.filter); - const tagFilter = datasource.replaceTemplateVars(query.itemTag?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; + const appFilter = interpolatedQuery.application?.filter; + const tagFilter = interpolatedQuery.itemTag?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/components/QueryEditor/UserMacrosQueryEditor.tsx b/src/datasource/components/QueryEditor/UserMacrosQueryEditor.tsx index c1d07f7..494bb8c 100644 --- a/src/datasource/components/QueryEditor/UserMacrosQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/UserMacrosQueryEditor.tsx @@ -9,6 +9,7 @@ import { MetricPicker } from '../../../components'; import { getVariableOptions } from './utils'; import { ZabbixDatasource } from '../../datasource'; import { ZabbixMetricsQuery } from '../../types/query'; +import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; export interface Props { query: ZabbixMetricsQuery; @@ -17,6 +18,7 @@ export interface Props { } export const UserMacrosQueryEditor = ({ query, datasource, onChange }: Props) => { + const interpolatedQuery = useInterpolatedQuery(datasource, query); const loadGroupOptions = async () => { const groups = await datasource.zabbix.getAllGroups(); const options = groups?.map((group) => ({ @@ -33,8 +35,7 @@ export const UserMacrosQueryEditor = ({ query, datasource, onChange }: Props) => }, []); const loadHostOptions = async (group: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hosts = await datasource.zabbix.getAllHosts(groupFilter); + const hosts = await datasource.zabbix.getAllHosts(group); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, @@ -46,14 +47,12 @@ export const UserMacrosQueryEditor = ({ query, datasource, onChange }: Props) => }; const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(query.group.filter); + const options = await loadHostOptions(interpolatedQuery.group.filter); return options; - }, [query.group.filter]); + }, [interpolatedQuery.group.filter]); const loadMacrosOptions = async (group: string, host: string) => { - const groupFilter = datasource.replaceTemplateVars(group); - const hostFilter = datasource.replaceTemplateVars(host); - const macros = await datasource.zabbix.getAllMacros(groupFilter, hostFilter); + const macros = await datasource.zabbix.getAllMacros(group, host); let options: Array> = macros?.map((m) => ({ value: m.macro, label: m.macro, @@ -65,13 +64,13 @@ export const UserMacrosQueryEditor = ({ query, datasource, onChange }: Props) => }; const [{ loading: macrosLoading, value: macrosOptions }, fetchmacros] = useAsyncFn(async () => { - const options = await loadMacrosOptions(query.group.filter, query.host.filter); + const options = await loadMacrosOptions(interpolatedQuery.group.filter, interpolatedQuery.host.filter); return options; - }, [query.group.filter, query.host.filter]); + }, [interpolatedQuery.group.filter, interpolatedQuery.host.filter]); // Update suggestions on every metric change - const groupFilter = datasource.replaceTemplateVars(query.group?.filter); - const hostFilter = datasource.replaceTemplateVars(query.host?.filter); + const groupFilter = interpolatedQuery.group?.filter; + const hostFilter = interpolatedQuery.host?.filter; useEffect(() => { fetchGroups(); diff --git a/src/datasource/datasource.ts b/src/datasource/datasource.ts index 8d18d28..b760aca 100644 --- a/src/datasource/datasource.ts +++ b/src/datasource/datasource.ts @@ -6,7 +6,6 @@ import * as utils from './utils'; import * as migrations from './migrations'; import * as metricFunctions from './metricFunctions'; import * as c from './constants'; -import dataProcessor from './dataProcessor'; import responseHandler from './responseHandler'; import problemsHandler from './problemsHandler'; import { Zabbix } from './zabbix/zabbix'; @@ -18,28 +17,27 @@ import { BackendSrvRequest, getBackendSrv, getTemplateSrv, - toDataQueryResponse, getDataSourceSrv, HealthCheckError, DataSourceWithBackend, + TemplateSrv, } from '@grafana/runtime'; import { DataFrame, dataFrameFromJSON, DataQueryRequest, DataQueryResponse, - DataSourceApi, DataSourceInstanceSettings, FieldType, isDataFrame, - LoadingState, + ScopedVars, toDataFrame, } from '@grafana/data'; import { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; import { trackRequest } from './tracking'; -import { lastValueFrom } from 'rxjs'; +import { lastValueFrom, map, Observable } from 'rxjs'; -export class ZabbixDatasource extends DataSourceApi { +export class ZabbixDatasource extends DataSourceWithBackend { name: string; basicAuth: any; withCredentials: any; @@ -59,9 +57,10 @@ export class ZabbixDatasource extends DataSourceApi; zabbix: Zabbix; - replaceTemplateVars: (target: any, scopedVars?: any) => any; - - constructor(instanceSettings: DataSourceInstanceSettings) { + constructor( + instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { super(instanceSettings); this.instanceSettings = instanceSettings; @@ -72,10 +71,6 @@ export class ZabbixDatasource extends DataSourceApi) { + query(request: DataQueryRequest): Observable { trackRequest(request); // Migrate old targets @@ -144,104 +138,33 @@ export class ZabbixDatasource extends DataSourceApi { - // Merge backend and frontend queries results - const [backendRes, dbConnectionRes, frontendRes, annotationRes] = rsp; - if (dbConnectionRes.data) { - backendRes.data = backendRes.data.concat(dbConnectionRes.data); - } - if (frontendRes.data) { - backendRes.data = backendRes.data.concat(frontendRes.data); - } + const applyMergeQueries = (queryResponse: DataQueryResponse) => + this.mergeQueries(queryResponse, dbConnectionResponsePromise, frontendResponsePromise, annotationResposePromise); + const applyFEFuncs = (queryResponse: DataQueryResponse) => + this.applyFrontendFunctions(queryResponse, { + ...request, + targets: interpolatedTargets.filter(this.isBackendTarget), + }); - if (annotationRes.data) { - backendRes.data = backendRes.data.concat(annotationRes.data); - } - - return { - data: backendRes.data, - state: LoadingState.Done, - key: request.requestId, - }; - }); - } - - async backendQuery(request: DataQueryRequest): Promise { - const { intervalMs, maxDataPoints, range, requestId } = request; - const targets = request.targets.filter(this.isBackendTarget); - - // Add range variables - request.scopedVars = Object.assign({}, request.scopedVars, utils.getRangeScopedVars(request.range)); - - const queries = _.compact( - targets.map((query) => { - // Don't request for hidden targets - if (query.hide) { - return null; - } - - this.replaceTargetVariables(query, request); - - return { - ...query, - datasourceId: this.datasourceId, - intervalMs, - maxDataPoints, - }; - }) + return backendResponse.pipe( + map(applyFEFuncs), + map(responseHandler.convertZabbixUnits), + map(this.convertToWide), + map(applyMergeQueries) ); - - // Return early if no queries exist - if (!queries.length) { - return Promise.resolve({ data: [] }); - } - - const body: any = { queries }; - - if (range) { - body.range = range; - body.from = range.from.valueOf().toString(); - body.to = range.to.valueOf().toString(); - } - - let rsp: any; - try { - rsp = await lastValueFrom( - getBackendSrv().fetch({ - url: '/api/ds/query', - method: 'POST', - data: body, - requestId, - }) - ); - } catch (err) { - return toDataQueryResponse(err); - } - - const resp = toDataQueryResponse(rsp); - this.sortByRefId(resp); - this.applyFrontendFunctions(resp, request); - responseHandler.convertZabbixUnits(resp); - if (responseHandler.isConvertibleToWide(resp.data)) { - console.log('Converting response to the wide format'); - resp.data = responseHandler.convertToWide(resp.data); - } - - return resp; } - async frontendQuery(request: DataQueryRequest): Promise { + async frontendQuery(request: DataQueryRequest): Promise { const frontendTargets = request.targets.filter((t) => !(this.isBackendTarget(t) || this.isDBConnectionTarget(t))); + const oldVersionTargets = frontendTargets + .filter((target) => (target as any).itservice && !target.itServiceFilter) + .map((t) => t.refId); const promises = _.map(frontendTargets, (target) => { // Don't request for hidden targets if (target.hide) { @@ -250,7 +173,6 @@ export class ZabbixDatasource extends DataSourceApi) { for (let i = 0; i < response.data.length; i++) { const frame: DataFrame = response.data[i]; - const target = getRequestTarget(request, frame.refId); + const target = utils.getRequestTarget(request, frame.refId); // Apply alias functions - const aliasFunctions = bindFunctionDefs(target.functions, 'Alias'); + const aliasFunctions = utils.bindFunctionDefs(target.functions, 'Alias'); utils.sequence(aliasFunctions)(frame); } return response; @@ -489,7 +411,7 @@ export class ZabbixDatasource extends DataSourceApi itemid.trim()); if (!itemids) { @@ -504,34 +426,26 @@ export class ZabbixDatasource extends DataSourceApi, + isOldVersion: boolean + ) { // Don't show undefined and hidden targets if (target.hide || (!(target as any).itservice && !target.itServiceFilter) || !target.slaProperty) { return []; } - let itServiceFilter; - request.isOldVersion = (target as any).itservice && !target.itServiceFilter; - - if (request.isOldVersion) { - // Backward compatibility - itServiceFilter = '/.*/'; - } else { - itServiceFilter = this.replaceTemplateVars(target.itServiceFilter, request.scopedVars); - } - - request.slaInterval = target.slaInterval; - - let itservices = await this.zabbix.getITServices(itServiceFilter); - if (request.isOldVersion) { + let itservices = await this.zabbix.getITServices(target.itServiceFilter); + if (isOldVersion) { itservices = _.filter(itservices, { serviceid: (target as any).itservice?.serviceid }); } if (target.slaFilter !== undefined) { - const slaFilter = this.replaceTemplateVars(target.slaFilter, request.scopedVars); - const slas = await this.zabbix.getSLAs(slaFilter); + const slas = await this.zabbix.getSLAs(target.slaFilter); const result = await this.zabbix.getSLI(itservices, slas, timeRange, target, request); // Apply alias functions - const aliasFunctions = bindFunctionDefs(target.functions, 'Alias'); + const aliasFunctions = utils.bindFunctionDefs(target.functions, 'Alias'); utils.sequence(aliasFunctions)(result); return result; } @@ -568,11 +482,10 @@ export class ZabbixDatasource extends DataSourceApi h.hostid); const appids = apps?.map((a) => a.applicationid); - const options = getTriggersOptions(target, timeRange); + const options = utils.getTriggersOptions(target, timeRange); - const tagsFilter = this.replaceTemplateVars(target.tags?.filter, request.scopedVars); - // replaceTemplateVars() builds regex-like string, so we should trim it. - const tagsFilterStr = tagsFilter.replace('/^', '').replace('$/', ''); + // variable interpolation builds regex-like string, so we should trim it. + const tagsFilterStr = target.tags.filter.replace('/^', '').replace('$/', ''); const tags = utils.parseTags(tagsFilterStr); tags.forEach((tag) => { // Zabbix uses {"tag": "", "value": "", "operator": ""} format, where 1 means Equal @@ -603,16 +516,15 @@ export class ZabbixDatasource extends DataSourceApi { // Zabbix uses {"tag": "", "value": "", "operator": ""} format, where 1 means Equal @@ -682,23 +594,8 @@ export class ZabbixDatasource extends DataSourceApi []; showAckButton = !this.disableReadOnlyUsersAck || userIsEditor; - // Replace template variables - const groupFilter = this.replaceTemplateVars(target.group?.filter, options.scopedVars); - const hostFilter = this.replaceTemplateVars(target.host?.filter, options.scopedVars); - const appFilter = this.replaceTemplateVars(target.application?.filter, options.scopedVars); - const proxyFilter = this.replaceTemplateVars(target.proxy?.filter, options.scopedVars); - - const triggerFilter = this.replaceTemplateVars(target.trigger?.filter, options.scopedVars); - const tagsFilter = this.replaceTemplateVars(target.tags?.filter, options.scopedVars); - - const replacedTarget = { - ...target, - trigger: { filter: triggerFilter }, - tags: { filter: tagsFilter }, - }; - // replaceTemplateVars() builds regex-like string, so we should trim it. - const tagsFilterStr = tagsFilter.replace('/^', '').replace('$/', ''); + const tagsFilterStr = target.tags.filter.replace('/^', '').replace('$/', ''); const tags = utils.parseTags(tagsFilterStr); tags.forEach((tag) => { // Zabbix uses {"tag": "", "value": "", "operator": ""} format, where 1 means Equal @@ -733,14 +630,20 @@ export class ZabbixDatasource extends DataSourceApi problemsHandler.setMaintenanceStatus(problems)) .then((problems) => problemsHandler.setAckButtonStatus(problems, showAckButton)) - .then((problems) => problemsHandler.filterTriggersPre(problems, replacedTarget)) + .then((problems) => problemsHandler.filterTriggersPre(problems, target)) .then((problems) => problemsHandler.sortProblems(problems, target)) .then((problems) => problemsHandler.addTriggerDataSource(problems, target)) .then((problems) => problemsHandler.formatAcknowledges(problems, zabbixUsers)) @@ -770,9 +673,8 @@ export class ZabbixDatasource extends DataSourceApi { let message = testResult.message; if (dbConnectorStatus) { @@ -839,7 +741,7 @@ export class ZabbixDatasource extends DataSourceApi { - return _.map(metrics, formatMetric); + return _.map(metrics, utils.formatMetric); }); } @@ -931,16 +833,16 @@ export class ZabbixDatasource extends DataSourceApi { // Filter triggers by description - const problemName = this.replaceTemplateVars(annotation.trigger.filter, {}); + const problemName = annotation.trigger.filter; if (utils.isRegex(problemName)) { problems = _.filter(problems, (p) => { return utils.buildRegex(problemName).test(p.description); @@ -977,35 +879,6 @@ export class ZabbixDatasource extends DataSourceApi { - if (target[p] && target[p].filter) { - target[p].filter = this.replaceTemplateVars(target[p].filter, options.scopedVars); - } - }); - - if (target.textFilter) { - target.textFilter = this.replaceTemplateVars(target.textFilter, options.scopedVars); - } - - if (target.itemids) { - target.itemids = templateSrv.replace(target.itemids, options.scopedVars, zabbixItemIdsTemplateFormat); - } - - _.forEach(target.functions, (func) => { - func.params = _.map(func.params, (param) => { - if (typeof param === 'number') { - return +templateSrv.replace(param.toString(), options.scopedVars); - } else { - return templateSrv.replace(param, options.scopedVars); - } - }); - }); - } - isUseTrends(timeRange, target: ZabbixMetricsQuery) { if (target.options.useTrends === 'false') { return false; @@ -1030,108 +903,89 @@ export class ZabbixDatasource extends DataSourceApi { return this.enableDirectDBConnection && (target.queryType === c.MODE_METRICS || target.queryType === c.MODE_ITEMID); }; -} -function bindFunctionDefs(functionDefs, category) { - const aggregationFunctions = _.map(metricFunctions.getCategories()[category], 'name'); - const aggFuncDefs = _.filter(functionDefs, (func) => { - return _.includes(aggregationFunctions, func.def.name) && func.params.length > 0; - }); + mergeQueries( + queryResponse: DataQueryResponse, + dbConnectionResponsePromise: Promise, + frontendResponsePromise: Promise, + annotationResposePromise: Promise + ): DataQueryResponse { + Promise.all([dbConnectionResponsePromise, frontendResponsePromise, annotationResposePromise]).then((resp) => { + const [dbConnectionRes, frontendRes, annotationRes] = resp; + if (dbConnectionRes.data) { + queryResponse.data = queryResponse.data.concat(dbConnectionRes.data); + } + if (frontendRes.data) { + queryResponse.data = queryResponse.data.concat(frontendRes.data); + } - return _.map(aggFuncDefs, (func) => { - const funcInstance = metricFunctions.createFuncInstance(func.def, func.params); - return funcInstance.bindFunction(dataProcessor.metricFunctions); - }); -} - -function getConsolidateBy(target) { - let consolidateBy; - const funcDef = _.find(target.functions, (func) => { - return func.def.name === 'consolidateBy'; - }); - if (funcDef && funcDef.params && funcDef.params.length) { - consolidateBy = funcDef.params[0]; - } - return consolidateBy; -} - -function formatMetric(metricObj) { - return { - text: metricObj.name, - expandable: false, - }; -} - -/** - * Custom formatter for template variables. - * Default Grafana "regex" formatter returns - * value1|value2 - * This formatter returns - * (value1|value2) - * This format needed for using in complex regex with - * template variables, for example - * /CPU $cpu_item.*time/ where $cpu_item is system,user,iowait - */ -export function zabbixTemplateFormat(value) { - if (typeof value === 'string') { - return utils.escapeRegex(value); + if (annotationRes.data) { + queryResponse.data = queryResponse.data.concat(annotationRes.data); + } + }); + return queryResponse; } - const escapedValues = _.map(value, utils.escapeRegex); - return '(' + escapedValues.join('|') + ')'; -} - -function zabbixItemIdsTemplateFormat(value) { - if (typeof value === 'string') { - return value; - } - return value.join(','); -} - -/** - * If template variables are used in request, replace it using regex format - * and wrap with '/' for proper multi-value work. Example: - * $variable selected as a, b, c - * We use filter $variable - * $variable -> a|b|c -> /a|b|c/ - * /$variable/ -> /a|b|c/ -> /a|b|c/ - */ -export function replaceTemplateVars(templateSrv, target, scopedVars) { - let replacedTarget = templateSrv.replace(target, scopedVars, zabbixTemplateFormat); - if (target && target !== replacedTarget && !utils.isRegex(replacedTarget)) { - replacedTarget = '/^' + replacedTarget + '$/'; - } - return replacedTarget; -} - -export function base64StringToArrowTable(text: string) { - const b64 = atob(text); - const arr = Uint8Array.from(b64, (c) => { - return c.charCodeAt(0); - }); - return arr; -} - -function getRequestTarget(request: DataQueryRequest, refId: string): any { - for (let i = 0; i < request.targets.length; i++) { - const target = request.targets[i]; - if (target.refId === refId) { - return target; + convertToWide(response: DataQueryResponse) { + if (responseHandler.isConvertibleToWide(response.data)) { + response.data = responseHandler.convertToWide(response.data); } + return response; + } + interpolateVariablesInQueries(queries: ZabbixMetricsQuery[], scopedVars: ScopedVars): ZabbixMetricsQuery[] { + if (!queries || queries.length === 0) { + return []; + } + return queries.map((query) => { + // backwardsCompatibility + const isOldVersion: boolean = (query as any).itservice && !query.itServiceFilter; + return { + ...query, + itServiceFilter: isOldVersion + ? '/.*/' + : utils.replaceTemplateVars(this.templateSrv, query.itServiceFilter, scopedVars), + slaFilter: utils.replaceTemplateVars(this.templateSrv, query.slaFilter, scopedVars), + itemids: utils.replaceTemplateVars( + this.templateSrv, + query.itemids, + scopedVars, + utils.zabbixItemIdsTemplateFormat + ), + textFilter: utils.replaceTemplateVars(this.templateSrv, query.textFilter, scopedVars), + functions: utils.replaceVariablesInFuncParams(this.templateSrv, query.functions, scopedVars), + tags: { + ...query.tags, + filter: utils.replaceTemplateVars(this.templateSrv, query.tags?.filter, scopedVars), + }, + group: { + ...query.group, + filter: utils.replaceTemplateVars(this.templateSrv, query.group?.filter, scopedVars), + }, + host: { + ...query.host, + filter: utils.replaceTemplateVars(this.templateSrv, query.host?.filter, scopedVars), + }, + application: { + ...query.application, + filter: utils.replaceTemplateVars(this.templateSrv, query.application?.filter, scopedVars), + }, + proxy: { + ...query.proxy, + filter: utils.replaceTemplateVars(this.templateSrv, query.proxy?.filter, scopedVars), + }, + trigger: { + ...query.trigger, + filter: utils.replaceTemplateVars(this.templateSrv, query.trigger?.filter, scopedVars), + }, + itemTag: { + ...query.itemTag, + filter: utils.replaceTemplateVars(this.templateSrv, query.itemTag?.filter, scopedVars), + }, + item: { + ...query.item, + filter: utils.replaceTemplateVars(this.templateSrv, query.item?.filter, scopedVars), + }, + }; + }); } - return null; } - -const getTriggersOptions = (target: ZabbixMetricsQuery, timeRange) => { - const [timeFrom, timeTo] = timeRange; - const options: any = { - minSeverity: target.options?.minSeverity, - acknowledged: target.options?.acknowledged, - count: target.options?.count, - }; - if (target.options?.useTimeRange) { - options.timeFrom = timeFrom; - options.timeTo = timeTo; - } - return options; -}; diff --git a/src/datasource/hooks/useInterpolatedQuery.ts b/src/datasource/hooks/useInterpolatedQuery.ts new file mode 100644 index 0000000..534668a --- /dev/null +++ b/src/datasource/hooks/useInterpolatedQuery.ts @@ -0,0 +1,22 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ScopedVars } from '@grafana/data'; +import { ZabbixDatasource } from '../datasource'; +import { ZabbixMetricsQuery } from '../types/query'; + +const EMPTY_SCOPED_VARS: ScopedVars = {}; + +export const useInterpolatedQuery = ( + datasource: ZabbixDatasource, + query: ZabbixMetricsQuery, + scopedVars?: ScopedVars +): ZabbixMetricsQuery => { + const [interpolatedQuery, setInterpolatedQuery] = useState(query); + const resolvedScopedVars = useMemo(() => scopedVars ?? EMPTY_SCOPED_VARS, [scopedVars]); + + useEffect(() => { + const replacedQuery = datasource.interpolateVariablesInQueries([query], resolvedScopedVars)[0]; + setInterpolatedQuery(replacedQuery); + }, [datasource, query, resolvedScopedVars]); + + return interpolatedQuery; +}; diff --git a/src/datasource/specs/datasource.spec.ts b/src/datasource/specs/datasource.spec.ts index a2c2d42..152ce32 100644 --- a/src/datasource/specs/datasource.spec.ts +++ b/src/datasource/specs/datasource.spec.ts @@ -1,26 +1,67 @@ -import { dateMath } from '@grafana/data'; +import { DataQueryResponse, dateMath } from '@grafana/data'; import _ from 'lodash'; import { datasourceSrvMock, templateSrvMock } from '../../test-setup/mocks'; -import { replaceTemplateVars, ZabbixDatasource, zabbixTemplateFormat } from '../datasource'; import { VariableQueryTypes } from '../types'; +import { ZabbixDatasource } from 'datasource/datasource'; +// firstValueFrom removed - tests call frontendQuery directly for text queries +import * as utils from '../utils'; jest.mock( '@grafana/runtime', - () => ({ - getBackendSrv: () => ({ - datasourceRequest: jest.fn().mockResolvedValue({ data: { result: '' } }), - fetch: () => ({ - toPromise: () => jest.fn().mockResolvedValue({ data: { result: '' } }), + () => { + const actual = jest.requireActual('@grafana/runtime'); + // Provide a custom query implementation that resolves backend + frontend + db + annotations + // so tests relying on merged results receive expected data. + if (actual && actual.DataSourceWithBackend && actual.DataSourceWithBackend.prototype) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + actual.DataSourceWithBackend.prototype.query = function (request: any) { + const that: any = this; + const { from } = require('rxjs'); + + const backendResponse = Promise.resolve({ data: [] }); + const dbPromise = that.dbConnectionQuery ? that.dbConnectionQuery(request) : Promise.resolve({ data: [] }); + const fePromise = that.frontendQuery ? that.frontendQuery(request) : Promise.resolve({ data: [] }); + const annPromise = that.annotationRequest ? that.annotationRequest(request) : Promise.resolve({ data: [] }); + + return from( + Promise.all([backendResponse, dbPromise, fePromise, annPromise]).then(([backend, db, fe, ann]) => { + const data: any[] = []; + if (backend && backend.data) { + data.push(...backend.data); + } + if (db && db.data) { + data.push(...db.data); + } + if (fe && fe.data) { + data.push(...fe.data); + } + if (ann && ann.data) { + data.push(...ann.data); + } + return { data }; + }) + ); + }; + } + + return { + ...actual, + getBackendSrv: () => ({ + datasourceRequest: jest.fn().mockResolvedValue({ data: { result: '' } }), + fetch: () => ({ + toPromise: () => jest.fn().mockResolvedValue({ data: { result: '' } }), + }), }), - }), - getDataSourceSrv: () => ({ - getInstanceSettings: jest.fn().mockResolvedValue({}), - }), - getTemplateSrv: () => ({ - replace: jest.fn().mockImplementation((query) => query), - }), - reportInteraction: jest.fn(), - }), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn().mockResolvedValue({}), + }), + getTemplateSrv: () => ({ + replace: jest.fn().mockImplementation((query) => query), + }), + reportInteraction: jest.fn(), + }; + }, { virtual: true } ); @@ -28,6 +69,24 @@ jest.mock('../components/AnnotationQueryEditor', () => ({ AnnotationQueryEditor: () => {}, })); +jest.mock( + '../utils', + () => ( + jest.requireActual('../utils'), + { + replaceVariablesInFuncParams: jest.fn(), + parseInterval: jest.fn(), + replaceTemplateVars: jest.fn().mockImplementation((templateSrv, prop) => prop), + getRangeScopedVars: jest.fn(), + bindFunctionDefs: jest.fn().mockResolvedValue([]), + parseLegacyVariableQuery: jest.fn(), + formatMetric: jest.fn().mockImplementation((metric) => { + return { text: metric.name, expandable: false }; + }), + } + ) +); + describe('ZabbixDatasource', () => { let ctx: any = {}; let consoleSpy: jest.SpyInstance; @@ -101,7 +160,7 @@ describe('ZabbixDatasource', () => { item: { filter: 'System information' }, textFilter: '', useCaptureGroups: true, - queryType: 2, + queryType: '2', resultFormat: 'table', options: { skipEmptyValues: false, @@ -110,25 +169,18 @@ describe('ZabbixDatasource', () => { ]; }); - it('should return data in table format', (done) => { - ctx.ds.query(ctx.options).then((result) => { - expect(result.data.length).toBe(1); + it('should return data in table format', async () => { + const result = (await ctx.ds.frontendQuery(ctx.options)) as DataQueryResponse; + expect(result.data.length).toBe(1); - let tableData = result.data[0]; - expect(tableData.columns).toEqual([ - { text: 'Host' }, - { text: 'Item' }, - { text: 'Key' }, - { text: 'Last value' }, - ]); - expect(tableData.rows).toEqual([['Zabbix server', 'System information', 'system.uname', 'Linux last']]); - done(); - }); + let tableData = result.data[0]; + expect(tableData.columns).toEqual([{ text: 'Host' }, { text: 'Item' }, { text: 'Key' }, { text: 'Last value' }]); + expect(tableData.rows).toEqual([['Zabbix server', 'System information', 'system.uname', 'Linux last']]); }); it('should extract value if regex with capture group is used', (done) => { ctx.options.targets[0].textFilter = 'Linux (.*)'; - ctx.ds.query(ctx.options).then((result) => { + ctx.ds.frontendQuery(ctx.options).then((result) => { let tableData = result.data[0]; expect(tableData.rows[0][3]).toEqual('last'); done(); @@ -163,7 +215,7 @@ describe('ZabbixDatasource', () => { { clock: '1500010500', itemid: '90109', ns: '900111000', value: '' }, ]) ); - return ctx.ds.query(ctx.options).then((result) => { + return ctx.ds.frontendQuery(ctx.options).then((result) => { let tableData = result.data[0]; expect(tableData.rows.length).toBe(1); expect(tableData.rows[0][3]).toEqual('Linux last'); @@ -171,69 +223,34 @@ describe('ZabbixDatasource', () => { }); }); - describe('When replacing template variables', () => { - function testReplacingVariable(target, varValue, expectedResult, done) { - ctx.ds.replaceTemplateVars = _.partial(replaceTemplateVars, { - replace: jest.fn((target) => zabbixTemplateFormat(varValue)), - }); - - let result = ctx.ds.replaceTemplateVars(target); - expect(result).toBe(expectedResult); - done(); - } - - /* - * Alphanumerics, spaces, dots, dashes and underscores - * are allowed in Zabbix host name. - * 'AaBbCc0123 .-_' - */ - it('should return properly escaped regex', (done) => { - let target = '$host'; - let template_var_value = 'AaBbCc0123 .-_'; - let expected_result = '/^AaBbCc0123 \\.-_$/'; - - testReplacingVariable(target, template_var_value, expected_result, done); - }); - - /* - * Single-value variable - * $host = backend01 - * $host => /^backend01|backend01$/ - */ - it('should return proper regex for single value', (done) => { - let target = '$host'; - let template_var_value = 'backend01'; - let expected_result = '/^backend01$/'; - - testReplacingVariable(target, template_var_value, expected_result, done); - }); - - /* - * Multi-value variable - * $host = [backend01, backend02] - * $host => /^(backend01|backend01)$/ - */ - it('should return proper regex for multi-value', (done) => { - let target = '$host'; - let template_var_value = ['backend01', 'backend02']; - let expected_result = '/^(backend01|backend02)$/'; - - testReplacingVariable(target, template_var_value, expected_result, done); - }); - }); - describe('When invoking metricFindQuery() with legacy query', () => { beforeEach(() => { - ctx.ds.replaceTemplateVars = (str) => str; ctx.ds.zabbix = { getGroups: jest.fn().mockReturnValue(Promise.resolve([])), getHosts: jest.fn().mockReturnValue(Promise.resolve([])), getApps: jest.fn().mockReturnValue(Promise.resolve([])), getItems: jest.fn().mockReturnValue(Promise.resolve([])), }; + + jest.spyOn(utils, 'replaceTemplateVars').mockImplementation(({}, prop: string, {}) => { + return prop; + }); }); it('should return groups', (done) => { + jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => { + let group = ''; + if (query === '*') { + group = '/.*/'; + } else { + group = query; + } + return { + queryType: VariableQueryTypes.Group, + group: group, + }; + }); + const tests = [ { query: '*', expect: '/.*/' }, { query: 'Backend', expect: 'Backend' }, @@ -259,6 +276,14 @@ describe('ZabbixDatasource', () => { }); it('should return hosts', (done) => { + jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => { + let splits = query.split('.'); + return { + queryType: VariableQueryTypes.Host, + group: splits[0] === '*' ? '/.*/' : splits[0], + host: splits[1] === '*' ? '/.*/' : splits[1], + }; + }); const tests = [ { query: '*.*', expect: ['/.*/', '/.*/'] }, { query: '.', expect: ['', ''] }, @@ -275,6 +300,15 @@ describe('ZabbixDatasource', () => { }); it('should return applications', (done) => { + jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => { + let splits = query.split('.'); + return { + queryType: VariableQueryTypes.Application, + group: splits[0] === '*' ? '/.*/' : splits[0], + host: splits[1] === '*' ? '/.*/' : splits[1], + application: splits[2] === '*' ? '/.*/' : splits[2], + }; + }); const tests = [ { query: '*.*.*', expect: ['/.*/', '/.*/', '/.*/'] }, { query: '.*.', expect: ['', '/.*/', ''] }, @@ -291,6 +325,16 @@ describe('ZabbixDatasource', () => { }); it('should return items', (done) => { + jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => { + let splits = query.split('.'); + return { + queryType: VariableQueryTypes.Item, + group: splits[0] === '*' ? '/.*/' : splits[0], + host: splits[1] === '*' ? '/.*/' : splits[1], + application: splits[2] === '*' ? '' : splits[2], + item: splits[3] === '*' ? '/.*/' : splits[3], + }; + }); const tests = [ { query: '*.*.*.*', expect: ['/.*/', '/.*/', '', undefined, '/.*/', { showDisabledItems: undefined }] }, { query: '.*.*.*', expect: ['', '/.*/', '', undefined, '/.*/', { showDisabledItems: undefined }] }, @@ -320,6 +364,14 @@ describe('ZabbixDatasource', () => { }); it('should invoke method with proper arguments', (done) => { + jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => { + let splits = query.split('.'); + return { + queryType: VariableQueryTypes.Host, + group: splits[0] === '*' ? '/.*/' : splits[0], + host: splits[1] === '*' ? '/.*/' : splits[1], + }; + }); let query = '*.*'; ctx.ds.metricFindQuery(query); @@ -329,7 +381,6 @@ describe('ZabbixDatasource', () => { describe('When invoking metricFindQuery()', () => { beforeEach(() => { - ctx.ds.replaceTemplateVars = (str) => str; ctx.ds.zabbix = { getGroups: jest.fn().mockReturnValue(Promise.resolve([{ name: 'Group1' }, { name: 'Group2' }])), getHosts: jest.fn().mockReturnValue(Promise.resolve([{ name: 'Host1' }, { name: 'Host2' }])), diff --git a/src/datasource/specs/utils.spec.ts b/src/datasource/specs/utils.spec.ts index 13c14e5..87d9398 100644 --- a/src/datasource/specs/utils.spec.ts +++ b/src/datasource/specs/utils.spec.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import * as utils from '../utils'; +import { replaceTemplateVars, zabbixTemplateFormat } from '../utils'; describe('Utils', () => { describe('expandItemName()', () => { @@ -174,4 +175,90 @@ describe('Utils', () => { } }); }); + + describe('replaceTemplateVars()', () => { + function testReplacingVariable(target, varValue, expectedResult, done) { + const templateSrv = { + replace: jest.fn((target) => zabbixTemplateFormat(varValue)), + getVariables: jest.fn(), + containsTemplate: jest.fn(), + updateTimeRange: jest.fn(), + }; + + let result = replaceTemplateVars(templateSrv, target, {}); + expect(result).toBe(expectedResult); + done(); + } + + /* + * Alphanumerics, spaces, dots, dashes and underscores + * are allowed in Zabbix host name. + * 'AaBbCc0123 .-_' + */ + it('should return properly escaped regex', (done) => { + let target = '$host'; + let template_var_value = 'AaBbCc0123 .-_'; + let expected_result = '/^AaBbCc0123 \\.-_$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + + /* + * Single-value variable + * $host = backend01 + * $host => /^backend01|backend01$/ + */ + it('should return proper regex for single value', (done) => { + let target = '$host'; + let template_var_value = 'backend01'; + let expected_result = '/^backend01$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + + /* + * Multi-value variable + * $host = [backend01, backend02] + * $host => /^(backend01|backend01)$/ + */ + it('should return proper regex for multi-value', (done) => { + let target = '$host'; + let template_var_value = ['backend01', 'backend02']; + let expected_result = '/^(backend01|backend02)$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + }); + + describe('replaceVariablesInFuncParams()', () => { + it('should interpolate numeric and string params with templateSrv', () => { + const replaceMock = jest + .fn() + .mockImplementation((value) => (value === '42' ? '100' : value.replace('$var', 'result'))); + const templateSrv = { replace: replaceMock }; + const scopedVars = { some: 'var' } as any; + const functions = [ + { + def: { name: 'test' }, + params: [42, '$var'], + }, + ]; + + const [fn] = utils.replaceVariablesInFuncParams(templateSrv as any, functions as any, scopedVars); + + expect(replaceMock).toHaveBeenCalledWith('42', scopedVars); + expect(replaceMock).toHaveBeenCalledWith('$var', scopedVars); + expect(fn.params).toEqual([100, 'result']); + }); + + it('should keep params undefined when function has none', () => { + const templateSrv = { replace: jest.fn() }; + const functions = [{ def: { name: 'noop' } }]; + + const [fn] = utils.replaceVariablesInFuncParams(templateSrv as any, functions as any, {} as any); + + expect(fn.params).toBeUndefined(); + expect(templateSrv.replace).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/datasource/utils.ts b/src/datasource/utils.ts index f3a905e..72c8109 100644 --- a/src/datasource/utils.ts +++ b/src/datasource/utils.ts @@ -5,13 +5,19 @@ import * as c from './constants'; import { VariableQuery, VariableQueryTypes, ZBXItemTag } from './types'; import { DataFrame, + DataQueryRequest, FieldType, getValueFormats, MappingType, rangeUtil, + ScopedVars, TIME_SERIES_TIME_FIELD_NAME, ValueMapping, } from '@grafana/data'; +import * as metricFunctions from './metricFunctions'; +import dataProcessor from './dataProcessor'; +import { MetricFunc, ZabbixMetricsQuery } from './types/query'; +import { TemplateSrv } from '@grafana/runtime'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -200,24 +206,12 @@ function isContainsBraces(query) { } // Pattern for testing regex -export const regexPattern = /^\/(.*)\/([gmi]*)$/m; +const regexPattern = /^\/(.*)\/([gmi]*)$/m; export function isRegex(str) { return regexPattern.test(str); } -export function isTemplateVariable(str, templateVariables) { - const variablePattern = /^\$\w+/; - if (variablePattern.test(str)) { - const variables = _.map(templateVariables, (variable) => { - return '$' + variable.name; - }); - return _.includes(variables, str); - } else { - return false; - } -} - export function getRangeScopedVars(range) { const msRange = range.to.diff(range.from); const sRange = Math.round(msRange / 1000); @@ -254,7 +248,7 @@ export function parseItemInterval(interval: string): number { return 0; } -export function normalizeZabbixInterval(interval: string): string { +function normalizeZabbixInterval(interval: string): string { const intervalPattern = /(^[\d]+)(y|M|w|d|h|m|s)?/g; const parsedInterval = intervalPattern.exec(interval); if (!parsedInterval || !interval || (parsedInterval.length > 2 && !parsedInterval[2])) { @@ -324,40 +318,6 @@ export function formatAcknowledges(acknowledges) { } } -export function convertToZabbixAPIUrl(url) { - const zabbixAPIUrlPattern = /.*api_jsonrpc.php$/; - const trimSlashPattern = /(.*?)[\/]*$/; - if (url.match(zabbixAPIUrlPattern)) { - return url; - } else { - return url.replace(trimSlashPattern, '$1'); - } -} - -/** - * Wrap function to prevent multiple calls - * when waiting for result. - */ -export function callOnce(func, promiseKeeper) { - return function () { - if (!promiseKeeper) { - promiseKeeper = Promise.resolve( - func - .apply(this, arguments) - .then((result) => { - promiseKeeper = null; - return result; - }) - .catch((err) => { - promiseKeeper = null; - throw err; - }) - ); - } - return promiseKeeper; - }; -} - /** * Apply function one by one: `sequence([a(), b(), c()]) = c(b(a()))` * @param {*} funcsArray functions to apply @@ -371,24 +331,6 @@ export function sequence(funcsArray) { }; } -const versionPattern = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z\.]+))?/; - -export function isValidVersion(version) { - return versionPattern.exec(version); -} - -export function parseVersion(version: string) { - const match = versionPattern.exec(version); - if (!match) { - return null; - } - const major = Number(match[1]); - const minor = Number(match[2] || 0); - const patch = Number(match[3] || 0); - const meta = match[4]; - return { major, minor, patch, meta }; -} - /** * Replaces any space-like symbols (tabs, new lines, spaces) by single whitespace. */ @@ -543,3 +485,125 @@ export function swap(list: T[], n: number, k: number): T[] { } return newList; } + +export function bindFunctionDefs(functionDefs, category) { + const aggregationFunctions = _.map(metricFunctions.getCategories()[category], 'name'); + const aggFuncDefs = _.filter(functionDefs, (func) => { + return _.includes(aggregationFunctions, func.def.name) && func.params.length > 0; + }); + + return _.map(aggFuncDefs, (func) => { + const funcInstance = metricFunctions.createFuncInstance(func.def, func.params); + return funcInstance.bindFunction(dataProcessor.metricFunctions); + }); +} + +export function getConsolidateBy(target) { + let consolidateBy; + const funcDef = _.find(target.functions, (func) => { + return func.def.name === 'consolidateBy'; + }); + if (funcDef && funcDef.params && funcDef.params.length) { + consolidateBy = funcDef.params[0]; + } + return consolidateBy; +} + +export function formatMetric(metricObj) { + return { + text: metricObj.name, + expandable: false, + }; +} + +/** + * Custom formatter for template variables. + * Default Grafana "regex" formatter returns + * value1|value2 + * This formatter returns + * (value1|value2) + * This format needed for using in complex regex with + * template variables, for example + * /CPU $cpu_item.*time/ where $cpu_item is system,user,iowait + */ +export function zabbixTemplateFormat(value) { + if (typeof value === 'string') { + return escapeRegex(value); + } + + const escapedValues = _.map(value, escapeRegex); + return '(' + escapedValues.join('|') + ')'; +} + +export function zabbixItemIdsTemplateFormat(value) { + if (typeof value === 'string') { + return value; + } + return value.join(','); +} + +/** + * If template variables are used in request, replace it using regex format + * and wrap with '/' for proper multi-value work. Example: + * $variable selected as a, b, c + * We use filter $variable + * $variable -> a|b|c -> /a|b|c/ + * /$variable/ -> /a|b|c/ -> /a|b|c/ + */ +export function replaceTemplateVars( + templateSrv: TemplateSrv, + target: string, + scopedVars: ScopedVars, + format: any = zabbixTemplateFormat +) { + let replacedTarget = templateSrv.replace(target, scopedVars, format); + if (target && target !== replacedTarget && !isRegex(replacedTarget)) { + replacedTarget = '/^' + replacedTarget + '$/'; + } + return replacedTarget; +} + +export function replaceVariablesInFuncParams( + templateSrv: TemplateSrv, + functions: MetricFunc[], + scopedVars: ScopedVars +) { + return functions?.map((func) => { + const interpolatedParams = func?.params?.map((param) => { + if (typeof param === 'number') { + return +templateSrv.replace(param.toString(), scopedVars); + } else { + return templateSrv.replace(param, scopedVars); + } + }); + + return { + ...func, + params: interpolatedParams, + }; + }); +} + +export function getRequestTarget(request: DataQueryRequest, refId: string): any { + for (let i = 0; i < request.targets.length; i++) { + const target = request.targets[i]; + if (target.refId === refId) { + return target; + } + } + return null; +} + +export const getTriggersOptions = (target: ZabbixMetricsQuery, timeRange) => { + const [timeFrom, timeTo] = timeRange; + const options: any = { + minSeverity: target.options?.minSeverity, + acknowledged: target.options?.acknowledged, + count: target.options?.count, + }; + if (target.options?.useTimeRange) { + options.timeFrom = timeFrom; + options.timeTo = timeTo; + } + return options; +};