From 0d64736e8665290f3ea340f776ebe65a3279cec7 Mon Sep 17 00:00:00 2001 From: Jocelyn Collado-Kuri Date: Mon, 5 Jan 2026 05:30:55 -0800 Subject: [PATCH] Adds support for host tags (#2140) ## Sumary When dealing with multiple hosts, it can be hard for customers filter through and figure out which host to query metric data from. This PR aims to make this easier by adding support for host tags so that there is another layer of filtering / grouping applied for hosts. ## Detailed explanation - Adds new UI components to allow adding one or more host tag filter, and a switch to choose between `AND/OR` and `OR` operators when using more than one filter following Zabbix's UI: https://github.com/user-attachments/assets/c971f5eb-7e93-4238-bd6b-902cc657c014 https://github.com/user-attachments/assets/5f8996de-684e-4ffa-b98e-8e205c4fc1df - Modifies the existing `getHosts` function to make a call to the backend with a few additional parameters to `extend` (essentially extract) the host tags for a given selected group. No backend changes were required for this. ## Why To make it easier for customers to query metric data when dealing with multiple hosts. ## How to test - Go to explore or a dashboard and create a Zabbix query where the query type is `Metrics` - The easiest way to test is by selecting `/.*/` for Groups, checking the returned `Hosts` they should all be there - Add a host tag filter and change the keys and operators as well as switching from `AND/OR` to `OR` you should see how the values returned for `Host` changes ## Future work Adding variable support for host tags once this is completed. Fixes: https://github.com/orgs/grafana/projects/457/views/40?pane=issue&itemId=3609900134&issue=grafana%7Coss-big-tent-squad%7C126 and https://github.com/grafana/grafana-zabbix/issues/927 --------- Co-authored-by: ismail simsek --- .changeset/funny-fans-fry.md | 5 + pkg/zabbix/methods.go | 10 +- pkg/zabbix/methods_test.go | 4 +- pkg/zabbix/models.go | 11 +- pkg/zabbix/utils.go | 6 +- .../QueryEditor/HostTagQueryEditor.tsx | 151 ++++++++++++++++++ .../QueryEditor/MetricsQueryEditor.tsx | 90 +++++++++-- .../components/QueryEditor/types.ts | 23 +++ .../components/QueryEditor/utils.test.ts | 89 +++++++++++ .../components/QueryEditor/utils.ts | 28 ++++ src/datasource/types/query.ts | 8 + .../zabbix_api/zabbixAPIConnector.test.ts | 76 +++++++++ .../zabbix_api/zabbixAPIConnector.ts | 26 ++- src/datasource/zabbix/types.ts | 12 ++ src/datasource/zabbix/zabbix.ts | 14 +- 15 files changed, 515 insertions(+), 38 deletions(-) create mode 100644 .changeset/funny-fans-fry.md create mode 100644 src/datasource/components/QueryEditor/HostTagQueryEditor.tsx create mode 100644 src/datasource/components/QueryEditor/types.ts create mode 100644 src/datasource/components/QueryEditor/utils.test.ts diff --git a/.changeset/funny-fans-fry.md b/.changeset/funny-fans-fry.md new file mode 100644 index 0000000..2417fb3 --- /dev/null +++ b/.changeset/funny-fans-fry.md @@ -0,0 +1,5 @@ +--- +'grafana-zabbix': minor +--- + +Add support for host tags when querying metrics diff --git a/pkg/zabbix/methods.go b/pkg/zabbix/methods.go index fb53581..372471c 100644 --- a/pkg/zabbix/methods.go +++ b/pkg/zabbix/methods.go @@ -233,7 +233,7 @@ func filterAppsByQuery(items []Application, filter string) ([]Application, error return filteredItems, nil } -func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilter string, tagFilter string) ([]ItemTag, error) { +func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilter string, tagFilter string) ([]Tag, error) { hosts, err := ds.GetHosts(ctx, groupFilter, hostFilter) if err != nil { return nil, err @@ -252,8 +252,8 @@ func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilte return nil, err } - var allTags []ItemTag - tagsMap := make(map[string]ItemTag) + var allTags []Tag + tagsMap := make(map[string]Tag) for _, item := range allItems { for _, itemTag := range item.Tags { tagStr := itemTagToString(itemTag) @@ -267,13 +267,13 @@ func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilte return filterTags(allTags, tagFilter) } -func filterTags(items []ItemTag, filter string) ([]ItemTag, error) { +func filterTags(items []Tag, filter string) ([]Tag, error) { re, err := parseFilter(filter) if err != nil { return nil, err } - filteredItems := make([]ItemTag, 0) + filteredItems := make([]Tag, 0) for _, i := range items { tagStr := itemTagToString(i) if re != nil { diff --git a/pkg/zabbix/methods_test.go b/pkg/zabbix/methods_test.go index 139bc9a..3c1383b 100644 --- a/pkg/zabbix/methods_test.go +++ b/pkg/zabbix/methods_test.go @@ -217,14 +217,14 @@ func TestGetItemTags(t *testing.T) { tags, err := client.GetItemTags(context.Background(), "Servers", "web01", "/^Env/") require.NoError(t, err) - assert.ElementsMatch(t, []ItemTag{ + assert.ElementsMatch(t, []Tag{ {Tag: "Env", Value: "prod"}, {Tag: "Env", Value: "stage"}, }, tags) } func TestFilterTags(t *testing.T) { - tags := []ItemTag{ + tags := []Tag{ {Tag: "Env", Value: "prod"}, {Tag: "Application", Value: "api"}, } diff --git a/pkg/zabbix/models.go b/pkg/zabbix/models.go index ef31415..8f865d2 100644 --- a/pkg/zabbix/models.go +++ b/pkg/zabbix/models.go @@ -51,7 +51,7 @@ type Item struct { Delay string `json:"delay,omitempty"` Units string `json:"units,omitempty"` ValueMapID string `json:"valuemapid,omitempty"` - Tags []ItemTag `json:"tags,omitempty"` + Tags []Tag `json:"tags,omitempty"` } type ItemHost struct { @@ -59,7 +59,7 @@ type ItemHost struct { Name string `json:"name,omitempty"` } -type ItemTag struct { +type Tag struct { Tag string `json:"tag,omitempty"` Value string `json:"value,omitempty"` } @@ -90,9 +90,10 @@ type Group struct { } type Host struct { - Name string `json:"name"` - Host string `json:"host"` - ID string `json:"hostid"` + Name string `json:"name"` + Host string `json:"host"` + ID string `json:"hostid"` + Tags []Tag `json:"tags,omitempty"` } type Application struct { diff --git a/pkg/zabbix/utils.go b/pkg/zabbix/utils.go index 80c2767..7a0c227 100644 --- a/pkg/zabbix/utils.go +++ b/pkg/zabbix/utils.go @@ -132,7 +132,7 @@ func isRegex(filter string) bool { return regex.MatchString(filter) } -func itemTagToString(tag ItemTag) string { +func itemTagToString(tag Tag) string { if tag.Value != "" { return fmt.Sprintf("%s: %s", tag.Tag, tag.Value) } else { @@ -140,8 +140,8 @@ func itemTagToString(tag ItemTag) string { } } -func parseItemTag(tagStr string) ItemTag { - tag := ItemTag{} +func parseItemTag(tagStr string) Tag { + tag := Tag{} firstIdx := strings.Index(tagStr, ":") if firstIdx > 0 { tag.Tag = strings.TrimSpace(tagStr[:firstIdx]) diff --git a/src/datasource/components/QueryEditor/HostTagQueryEditor.tsx b/src/datasource/components/QueryEditor/HostTagQueryEditor.tsx new file mode 100644 index 0000000..a0afcf1 --- /dev/null +++ b/src/datasource/components/QueryEditor/HostTagQueryEditor.tsx @@ -0,0 +1,151 @@ +import { Tooltip, Button, Combobox, ComboboxOption, Stack, Input, RadioButtonGroup } from '@grafana/ui'; +import React, { FormEvent, useCallback, useEffect, useState } from 'react'; +import { HostTagOperatorLabel, HostTagOperatorValue } from './types'; +import { HostTagFilter, ZabbixTagEvalType } from 'datasource/types/query'; +import { getHostTagOptionLabel } from './utils'; + +interface Props { + hostTagOptions: ComboboxOption[]; + hostTagOptionsLoading: boolean; + version: string; + evalTypeValue?: ZabbixTagEvalType; + onHostTagFilterChange?: (hostTags: HostTagFilter[]) => void; + onHostTagEvalTypeChange?: (evalType: ZabbixTagEvalType) => void; +} + +export const HostTagQueryEditor = ({ + hostTagOptions, + hostTagOptionsLoading, + version, + evalTypeValue, + onHostTagFilterChange, + onHostTagEvalTypeChange, +}: Props) => { + const [hostTagFilters, setHostTagFilters] = useState([]); + const [hostTagValueDrafts, setHostTagValueDrafts] = useState([]); + const operatorOptions: ComboboxOption[] = [ + { value: HostTagOperatorValue.Exists, label: HostTagOperatorLabel.Exists }, + { value: HostTagOperatorValue.Equals, label: HostTagOperatorLabel.Equals }, + { value: HostTagOperatorValue.Contains, label: HostTagOperatorLabel.Contains }, + { + value: HostTagOperatorValue.DoesNotExist, + label: getHostTagOptionLabel(HostTagOperatorValue.DoesNotExist, version), + }, + { + value: HostTagOperatorValue.DoesNotEqual, + label: getHostTagOptionLabel(HostTagOperatorValue.DoesNotEqual, version), + }, + { + value: HostTagOperatorValue.DoesNotContain, + label: getHostTagOptionLabel(HostTagOperatorValue.DoesNotContain, version), + }, + ]; + + const onAddHostTagFilter = useCallback(() => { + setHostTagFilters((prevFilters) => [ + ...prevFilters, + { tag: '', value: '', operator: HostTagOperatorValue.Contains }, + ]); + setHostTagValueDrafts((prevDrafts) => [...prevDrafts, '']); + }, []); + + const onRemoveHostTagFilter = useCallback((index: number) => { + setHostTagFilters((prevFilters) => prevFilters.filter((_, i) => i !== index)); + setHostTagValueDrafts((prevDrafts) => prevDrafts.filter((_, i) => i !== index)); + }, []); + + const setHostTagFilterName = useCallback((index: number, name: string) => { + setHostTagFilters((prevFilters) => + prevFilters.map((filter, i) => (i === index ? { ...filter, tag: name } : filter)) + ); + }, []); + + const setHostTagFilterValue = useCallback((index: number, value: string) => { + if (value !== undefined) { + setHostTagFilters((prevFilters) => + prevFilters.map((filter, i) => (i === index ? { ...filter, value: value } : filter)) + ); + } + }, []); + + const setHostTagFilterOperator = useCallback((index: number, operator: HostTagOperatorValue) => { + setHostTagFilters((prevFilters) => + prevFilters.map((filter, i) => (i === index ? { ...filter, operator } : filter)) + ); + }, []); + + useEffect(() => { + onHostTagFilterChange(hostTagFilters); + }, [hostTagFilters]); + + return ( +
+ + +
+ ); +}; diff --git a/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx b/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx index 2907103..ece283d 100644 --- a/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx +++ b/src/datasource/components/QueryEditor/MetricsQueryEditor.tsx @@ -1,15 +1,16 @@ -import _ from 'lodash'; -import React, { useEffect } from 'react'; +import { flatten, uniqBy } from 'lodash'; +import React, { useCallback, useEffect } from 'react'; import { useAsyncFn } from 'react-use'; import { InlineField, ComboboxOption } from '@grafana/ui'; import { QueryEditorRow } from './QueryEditorRow'; import { MetricPicker } from '../../../components'; -import { getVariableOptions } from './utils'; +import { getVariableOptions, processHostTags } from './utils'; import { ZabbixDatasource } from '../../datasource'; -import { ZabbixMetricsQuery } from '../../types/query'; +import { HostTagFilter, ZabbixMetricsQuery, ZabbixTagEvalType } from '../../types/query'; import { ZBXItem, ZBXItemTag } from '../../types'; import { itemTagToString } from '../../utils'; +import { HostTagQueryEditor } from './HostTagQueryEditor'; import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; export interface Props { @@ -28,6 +29,9 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha value: group.name, label: group.name, })); + if (options.length > 0) { + options.unshift({ value: '/.*/' }); + } options.unshift(...getVariableOptions()); return options; }; @@ -37,22 +41,44 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha return options; }, []); - const loadHostOptions = async (group: string) => { - const hosts = await datasource.zabbix.getAllHosts(group); + const loadHostTagOptions = async (group: string) => { + const hostsWithTags = await datasource.zabbix.getAllHosts(group, true); + const hostTags = processHostTags(hostsWithTags ?? []); + let options: Array> = hostTags?.map((tag) => ({ + value: tag.tag, + label: tag.tag, + })); + return options; + }; + + const loadHostOptions = async (group: string, hostTags?: HostTagFilter[], evalType?: ZabbixTagEvalType) => { + const hosts = await datasource.zabbix.getAllHosts(group, false, hostTags, evalType); let options: Array> = hosts?.map((host) => ({ value: host.name, label: host.name, })); - options = _.uniqBy(options, (o) => o.value); - options.unshift({ value: '/.*/' }); + options = uniqBy(options, (o) => o.value); + if (options.length > 0) { + options.unshift({ value: '/.*/' }); + } options.unshift(...getVariableOptions()); return options; }; - const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { - const options = await loadHostOptions(interpolatedQuery.group.filter); + const [{ loading: hostTagsLoading, value: hostTagsOptions }, fetchHostTags] = useAsyncFn(async () => { + const options = await loadHostTagOptions(query.group.filter); return options; - }, [interpolatedQuery.group.filter]); + }, [query.group.filter]); + + const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { + const options = await loadHostOptions( + interpolatedQuery.group.filter, + interpolatedQuery.hostTags, + interpolatedQuery.evaltype + ); + + return options; + }, [interpolatedQuery.group.filter, interpolatedQuery.hostTags, interpolatedQuery.evaltype]); const loadAppOptions = async (group: string, host: string) => { const apps = await datasource.zabbix.getAllApps(group, host); @@ -60,7 +86,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha value: app.name, label: app.name, })); - options = _.uniqBy(options, (o) => o.value); + options = uniqBy(options, (o) => o.value); options.unshift(...getVariableOptions()); return options; }; @@ -77,15 +103,15 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha } const items = await datasource.zabbix.getAllItems(group, host, null, null, {}); - const tags: ZBXItemTag[] = _.flatten(items.map((item: ZBXItem) => item.tags || [])); + const tags: ZBXItemTag[] = flatten(items.map((item: ZBXItem) => item.tags || [])); // const tags: ZBXItemTag[] = await datasource.zabbix.getItemTags(groupFilter, hostFilter, null); - const tagList = _.uniqBy(tags, (t) => t.tag + t.value || '').map((t) => itemTagToString(t)); + const tagList = uniqBy(tags, (t) => t.tag + t.value || '').map((t) => itemTagToString(t)); let options: Array> = tagList?.map((tag) => ({ value: tag, label: tag, })); - options = _.uniqBy(options, (o) => o.value); + options = uniqBy(options, (o) => o.value); options.unshift(...getVariableOptions()); return options; }; @@ -123,7 +149,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha value: item.name, label: item.name, })); - itemOptions = _.uniqBy(itemOptions, (o) => o.value); + itemOptions = uniqBy(itemOptions, (o) => o.value); itemOptions.unshift(...getVariableOptions()); return itemOptions; }; @@ -147,6 +173,8 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha // Update suggestions on every metric change const groupFilter = interpolatedQuery.group?.filter; + const hostTagFilters = interpolatedQuery.hostTags; + const evalType = interpolatedQuery.evaltype; const hostFilter = interpolatedQuery.host?.filter; const appFilter = interpolatedQuery.application?.filter; const tagFilter = interpolatedQuery.itemTag?.filter; @@ -157,9 +185,13 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha }, []); useEffect(() => { - fetchHosts(); + fetchHostTags(); }, [groupFilter]); + useEffect(() => { + fetchHosts(); + }, [groupFilter, hostTagFilters, evalType]); + useEffect(() => { fetchApps(); }, [groupFilter, hostFilter]); @@ -180,6 +212,20 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha }; }; + const onHostTagFilterChange = useCallback( + (hostTags: HostTagFilter[]) => { + onChange({ ...query, hostTags: hostTags }); + }, + [onChange, query] + ); + + const onHostTagEvalTypeChange = useCallback( + (evalType: ZabbixTagEvalType) => { + onChange({ ...query, evaltype: evalType }); + }, + [onChange, query] + ); + const supportsApplications = datasource.zabbix.supportsApplications(); return ( @@ -195,6 +241,16 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha placeholder="Group name" /> + + + ({ + getTemplateSrv: jest.fn(), + }), + { virtual: true } +); + +describe('QueryEditor utils', () => { + describe('getVariableOptions', () => { + it('returns template variables except datasource and interval types', () => { + (getTemplateSrv as jest.Mock).mockReturnValue({ + getVariables: jest.fn().mockReturnValue([ + { name: 'env', type: 'query' }, + { name: 'ds', type: 'datasource' }, + { name: 'step', type: 'interval' }, + { name: 'region', type: 'custom' }, + ]), + }); + + const options = getVariableOptions(); + + expect(options).toEqual([ + { label: '$env', value: '$env' }, + { label: '$region', value: '$region' }, + ]); + }); + }); + + describe('processHostTags', () => { + it('deduplicates tags by tag key', () => { + const tags = processHostTags([ + { + host: 'a', + name: 'a', + tags: [ + { tag: 'env', value: 'prod' }, + { tag: 'role', value: 'api' }, + ], + }, + { + host: 'b', + name: 'b', + tags: [ + { tag: 'env', value: 'stage' }, + { tag: 'region', value: 'eu' }, + ], + }, + { host: 'c', name: 'c' }, + ]); + + expect(tags).toEqual([ + { tag: 'env', value: 'prod' }, + { tag: 'role', value: 'api' }, + { tag: 'region', value: 'eu' }, + ]); + }); + }); + + describe('getHostTagOptionLabel', () => { + it('returns pre-7.0 labels for legacy versions', () => { + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotExist, '6.4.0')).toBe( + HostTagOperatorLabelBefore70.NotExist + ); + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotEqual, '6.0.0')).toBe( + HostTagOperatorLabelBefore70.NotEqual + ); + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotContain, '5.0.0')).toBe( + HostTagOperatorLabelBefore70.NotLike + ); + }); + + it('returns current labels for 7.0 and newer', () => { + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotExist, '7.0.0')).toBe(HostTagOperatorLabel.DoesNotExist); + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotEqual, '7.1.0')).toBe(HostTagOperatorLabel.DoesNotEqual); + expect(getHostTagOptionLabel(HostTagOperatorValue.DoesNotContain, '7.2.0')).toBe( + HostTagOperatorLabel.DoesNotContain + ); + }); + + it('returns empty string for unsupported values', () => { + expect(getHostTagOptionLabel(HostTagOperatorValue.Equals, '7.2.0')).toBe(''); + }); + }); +}); diff --git a/src/datasource/components/QueryEditor/utils.ts b/src/datasource/components/QueryEditor/utils.ts index e47de77..472730c 100644 --- a/src/datasource/components/QueryEditor/utils.ts +++ b/src/datasource/components/QueryEditor/utils.ts @@ -1,4 +1,7 @@ +import { uniqBy } from 'lodash'; import { getTemplateSrv } from '@grafana/runtime'; +import { Host, Tag } from 'datasource/zabbix/types'; +import { HostTagOperatorLabel, HostTagOperatorLabelBefore70, HostTagOperatorValue } from './types'; export const getVariableOptions = () => { const variables = getTemplateSrv() @@ -11,3 +14,28 @@ export const getVariableOptions = () => { label: `$${v.name}`, })); }; + +export function processHostTags(hosts: Host[]): Tag[] { + const hostTags = hosts.map((host) => host.tags || []).flat(); + // deduplicate tags + const uniqueHostTags = uniqBy(hostTags, (tag) => tag.tag); + return uniqueHostTags; +} + +/** + * Get the label for a host tag option + * Zabbix changed some of the operator labels in version 7.0.0 but the value equivalents remained the same. + * this function helps fetch the right label value for those that are different. + */ +export function getHostTagOptionLabel(value: HostTagOperatorValue, version: string): string { + switch (value) { + case HostTagOperatorValue.DoesNotExist: + return version < '7.0.0' ? HostTagOperatorLabelBefore70.NotExist : HostTagOperatorLabel.DoesNotExist; + case HostTagOperatorValue.DoesNotEqual: + return version < '7.0.0' ? HostTagOperatorLabelBefore70.NotEqual : HostTagOperatorLabel.DoesNotEqual; + case HostTagOperatorValue.DoesNotContain: + return version < '7.0.0' ? HostTagOperatorLabelBefore70.NotLike : HostTagOperatorLabel.DoesNotContain; + default: + return ''; + } +} diff --git a/src/datasource/types/query.ts b/src/datasource/types/query.ts index 26856cd..9b476d3 100644 --- a/src/datasource/types/query.ts +++ b/src/datasource/types/query.ts @@ -1,5 +1,6 @@ import { DataQuery } from '@grafana/schema'; import * as c from './../constants'; +import { HostTagOperatorValue } from 'datasource/components/QueryEditor/types'; export type QueryType = | typeof c.MODE_METRICS @@ -24,6 +25,7 @@ export type ZabbixMetricsQuery = { mode: number; itemids: string; useCaptureGroups: boolean; + hostTags?: HostTagFilter[]; proxy?: { filter: string }; trigger?: { filter: string }; itServiceFilter?: string; @@ -108,3 +110,9 @@ export enum ZabbixTagEvalType { AndOr = '0', Or = '2', } + +export interface HostTagFilter { + tag: string; + value: string; + operator: HostTagOperatorValue; +} diff --git a/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.test.ts b/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.test.ts index 05612aa..322bd63 100644 --- a/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.test.ts +++ b/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.test.ts @@ -1,4 +1,6 @@ import { ZabbixAPIConnector } from './zabbixAPIConnector'; +import { HostTagOperatorValue } from '../../../components/QueryEditor/types'; +import { ZabbixTagEvalType } from 'datasource/types/query'; describe('Zabbix API connector', () => { describe('getProxies function', () => { @@ -154,6 +156,80 @@ describe('Zabbix API connector', () => { expect(params.applicationids).toBeUndefined(); }); }); + + describe('getHosts', () => { + it('passes base params and group ids', () => { + const zabbixAPIConnector = new ZabbixAPIConnector(true, true, 123); + zabbixAPIConnector.request = jest.fn(); + + zabbixAPIConnector.getHosts(['1', '2']); + + expect(zabbixAPIConnector.request).toHaveBeenCalledWith('host.get', { + output: ['hostid', 'name', 'host'], + sortfield: 'name', + groupids: ['1', '2'], + }); + }); + + it('requests tags when getHostTags is true', () => { + const zabbixAPIConnector = new ZabbixAPIConnector(true, true, 123); + zabbixAPIConnector.request = jest.fn(); + + zabbixAPIConnector.getHosts(undefined, true); + + expect(zabbixAPIConnector.request).toHaveBeenCalledWith('host.get', { + output: ['hostid', 'name', 'host', 'tags'], + sortfield: 'name', + selectTags: 'extend', + }); + }); + + it('builds tag filters with numeric operator and evaltype', () => { + const zabbixAPIConnector = new ZabbixAPIConnector(true, true, 123); + zabbixAPIConnector.request = jest.fn(); + + zabbixAPIConnector.getHosts( + undefined, + false, + [ + { tag: 'role', value: 'api', operator: HostTagOperatorValue.Contains }, + { tag: '', value: 'ignore me', operator: HostTagOperatorValue.Equals }, + ], + ZabbixTagEvalType.Or + ); + + expect(zabbixAPIConnector.request).toHaveBeenCalledWith('host.get', { + output: ['hostid', 'name', 'host'], + sortfield: 'name', + selectTags: 'extend', + evaltype: 2, + tags: [{ tag: 'role', value: 'api', operator: 0 }], + }); + }); + + it('builds tag filters with numeric operator and default evaltype when using unsupported evalType', () => { + const zabbixAPIConnector = new ZabbixAPIConnector(true, true, 123); + zabbixAPIConnector.request = jest.fn(); + + zabbixAPIConnector.getHosts( + undefined, + false, + [ + { tag: 'role', value: 'api', operator: HostTagOperatorValue.Contains }, + { tag: '', value: 'ignore me', operator: HostTagOperatorValue.Equals }, + ], + '3' as ZabbixTagEvalType + ); + + expect(zabbixAPIConnector.request).toHaveBeenCalledWith('host.get', { + output: ['hostid', 'name', 'host'], + sortfield: 'name', + selectTags: 'extend', + evaltype: 0, + tags: [{ tag: 'role', value: 'api', operator: 0 }], + }); + }); + }); }); const triggers = [ diff --git a/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts b/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts index a0cb700..feb1f3a 100644 --- a/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts +++ b/src/datasource/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts @@ -3,7 +3,7 @@ import semver from 'semver'; import kbn from 'grafana/app/core/utils/kbn'; import * as utils from '../../../utils'; import { MIN_SLA_INTERVAL, ZBX_ACK_ACTION_ADD_MESSAGE, ZBX_ACK_ACTION_NONE } from '../../../constants'; -import { ShowProblemTypes } from '../../../types/query'; +import { HostTagFilter, ShowProblemTypes, ZabbixTagEvalType } from '../../../types/query'; import { ZBXProblem, ZBXTrigger } from '../../../types'; import { APIExecuteScriptResponse, JSONRPCError, ZBXScript } from './types'; import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; @@ -149,7 +149,12 @@ export class ZabbixAPIConnector { return this.request('hostgroup.get', params); } - getHosts(groupids): Promise { + getHosts( + groupids: string[], + getHostTags?: boolean, + hostTagFilters?: HostTagFilter[], + evalType?: ZabbixTagEvalType + ): Promise { const params: any = { output: ['hostid', 'name', 'host'], sortfield: 'name', @@ -158,6 +163,23 @@ export class ZabbixAPIConnector { params.groupids = groupids; } + if (getHostTags) { + params.output.push('tags'); + params.selectTags = 'extend'; + } + + if (hostTagFilters && hostTagFilters.length > 0) { + params.selectTags = 'extend'; + params.evaltype = evalType === ZabbixTagEvalType.Or || evalType === ZabbixTagEvalType.AndOr ? +evalType : 0; + + // ensure only non empty tag keys are being sent + // convert operator to number since that is the expected type in Zabbix. + params.tags = hostTagFilters + .filter((tagFilter) => tagFilter.tag !== '') + .map((tagFilter) => { + return { ...tagFilter, operator: +tagFilter.operator }; + }); + } return this.request('host.get', params); } diff --git a/src/datasource/zabbix/types.ts b/src/datasource/zabbix/types.ts index 8e09d78..6259e54 100644 --- a/src/datasource/zabbix/types.ts +++ b/src/datasource/zabbix/types.ts @@ -50,3 +50,15 @@ export interface ZabbixConnector { supportsApplications: () => boolean; } + +export interface Host { + host: string; + name: string; + hostid?: string; + tags?: Tag[]; +} + +export interface Tag { + tag: string; + value: string; +} diff --git a/src/datasource/zabbix/zabbix.ts b/src/datasource/zabbix/zabbix.ts index 7561d06..ad66ea9 100644 --- a/src/datasource/zabbix/zabbix.ts +++ b/src/datasource/zabbix/zabbix.ts @@ -9,9 +9,9 @@ import { DBConnector } from './connectors/dbConnector'; import { ZabbixAPIConnector } from './connectors/zabbix_api/zabbixAPIConnector'; import { SQLConnector } from './connectors/sql/sqlConnector'; import { InfluxDBConnector } from './connectors/influxdb/influxdbConnector'; -import { ZabbixConnector } from './types'; +import { Host, ZabbixConnector } from './types'; import { joinTriggersWithEvents, joinTriggersWithProblems } from '../problemsHandler'; -import { ZabbixMetricsQuery } from '../types/query'; +import { HostTagFilter, ZabbixMetricsQuery, ZabbixTagEvalType } from '../types/query'; import { ProblemDTO, ZBXApp, ZBXHost, ZBXItem, ZBXItemTag, ZBXTrigger } from '../types'; interface AppsResponse extends Array { @@ -296,6 +296,7 @@ export class Zabbix implements ZabbixConnector { } getAllGroups() { + console.log(this.zabbixAPI.getGroups()); return this.zabbixAPI.getGroups(); } @@ -306,10 +307,15 @@ export class Zabbix implements ZabbixConnector { /** * Get list of host belonging to given groups. */ - getAllHosts(groupFilter): Promise { + getAllHosts( + groupFilter: string, + getHostTags?: boolean, + hostTagFilters?: HostTagFilter[], + evalType?: ZabbixTagEvalType + ): Promise { return this.getGroups(groupFilter).then((groups) => { const groupids = _.map(groups, 'groupid'); - return this.zabbixAPI.getHosts(groupids); + return this.zabbixAPI.getHosts(groupids, getHostTags, hostTagFilters, evalType); }); }