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 <ismailsimsek09@gmail.com>
This commit is contained in:
Jocelyn Collado-Kuri
2026-01-05 05:30:55 -08:00
committed by GitHub
parent 3d0895c008
commit 0d64736e86
15 changed files with 515 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
---
'grafana-zabbix': minor
---
Add support for host tags when querying metrics

View File

@@ -233,7 +233,7 @@ func filterAppsByQuery(items []Application, filter string) ([]Application, error
return filteredItems, nil 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) hosts, err := ds.GetHosts(ctx, groupFilter, hostFilter)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -252,8 +252,8 @@ func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilte
return nil, err return nil, err
} }
var allTags []ItemTag var allTags []Tag
tagsMap := make(map[string]ItemTag) tagsMap := make(map[string]Tag)
for _, item := range allItems { for _, item := range allItems {
for _, itemTag := range item.Tags { for _, itemTag := range item.Tags {
tagStr := itemTagToString(itemTag) tagStr := itemTagToString(itemTag)
@@ -267,13 +267,13 @@ func (ds *Zabbix) GetItemTags(ctx context.Context, groupFilter string, hostFilte
return filterTags(allTags, tagFilter) return filterTags(allTags, tagFilter)
} }
func filterTags(items []ItemTag, filter string) ([]ItemTag, error) { func filterTags(items []Tag, filter string) ([]Tag, error) {
re, err := parseFilter(filter) re, err := parseFilter(filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
filteredItems := make([]ItemTag, 0) filteredItems := make([]Tag, 0)
for _, i := range items { for _, i := range items {
tagStr := itemTagToString(i) tagStr := itemTagToString(i)
if re != nil { if re != nil {

View File

@@ -217,14 +217,14 @@ func TestGetItemTags(t *testing.T) {
tags, err := client.GetItemTags(context.Background(), "Servers", "web01", "/^Env/") tags, err := client.GetItemTags(context.Background(), "Servers", "web01", "/^Env/")
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, []ItemTag{ assert.ElementsMatch(t, []Tag{
{Tag: "Env", Value: "prod"}, {Tag: "Env", Value: "prod"},
{Tag: "Env", Value: "stage"}, {Tag: "Env", Value: "stage"},
}, tags) }, tags)
} }
func TestFilterTags(t *testing.T) { func TestFilterTags(t *testing.T) {
tags := []ItemTag{ tags := []Tag{
{Tag: "Env", Value: "prod"}, {Tag: "Env", Value: "prod"},
{Tag: "Application", Value: "api"}, {Tag: "Application", Value: "api"},
} }

View File

@@ -51,7 +51,7 @@ type Item struct {
Delay string `json:"delay,omitempty"` Delay string `json:"delay,omitempty"`
Units string `json:"units,omitempty"` Units string `json:"units,omitempty"`
ValueMapID string `json:"valuemapid,omitempty"` ValueMapID string `json:"valuemapid,omitempty"`
Tags []ItemTag `json:"tags,omitempty"` Tags []Tag `json:"tags,omitempty"`
} }
type ItemHost struct { type ItemHost struct {
@@ -59,7 +59,7 @@ type ItemHost struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
} }
type ItemTag struct { type Tag struct {
Tag string `json:"tag,omitempty"` Tag string `json:"tag,omitempty"`
Value string `json:"value,omitempty"` Value string `json:"value,omitempty"`
} }
@@ -90,9 +90,10 @@ type Group struct {
} }
type Host struct { type Host struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
ID string `json:"hostid"` ID string `json:"hostid"`
Tags []Tag `json:"tags,omitempty"`
} }
type Application struct { type Application struct {

View File

@@ -132,7 +132,7 @@ func isRegex(filter string) bool {
return regex.MatchString(filter) return regex.MatchString(filter)
} }
func itemTagToString(tag ItemTag) string { func itemTagToString(tag Tag) string {
if tag.Value != "" { if tag.Value != "" {
return fmt.Sprintf("%s: %s", tag.Tag, tag.Value) return fmt.Sprintf("%s: %s", tag.Tag, tag.Value)
} else { } else {
@@ -140,8 +140,8 @@ func itemTagToString(tag ItemTag) string {
} }
} }
func parseItemTag(tagStr string) ItemTag { func parseItemTag(tagStr string) Tag {
tag := ItemTag{} tag := Tag{}
firstIdx := strings.Index(tagStr, ":") firstIdx := strings.Index(tagStr, ":")
if firstIdx > 0 { if firstIdx > 0 {
tag.Tag = strings.TrimSpace(tagStr[:firstIdx]) tag.Tag = strings.TrimSpace(tagStr[:firstIdx])

View File

@@ -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<HostTagFilter[]>([]);
const [hostTagValueDrafts, setHostTagValueDrafts] = useState<string[]>([]);
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 (
<div>
<Stack direction="row">
<Tooltip content="Add host tag filter">
<Button icon="plus" variant="secondary" aria-label="Add new host tag filter" onClick={onAddHostTagFilter} />
</Tooltip>
{hostTagFilters.length > 0 && (
<RadioButtonGroup
options={[
{ label: 'AND/OR', value: '0' }, // Default
{ label: 'OR', value: '2' },
]}
onChange={onHostTagEvalTypeChange}
value={evalTypeValue ?? '0'}
/>
)}
</Stack>
<Stack direction="column">
{hostTagFilters.map((filter, index) => {
return (
<Stack key={`host-tag-filter-${index}`} direction="row">
<Combobox
value={filter.tag}
onChange={(option: ComboboxOption) => setHostTagFilterName(index, option.value)}
options={hostTagOptions ?? []}
width={19}
loading={hostTagOptionsLoading}
/>
<Combobox
value={filter.operator}
onChange={(option: ComboboxOption<HostTagOperatorValue>) =>
setHostTagFilterOperator(index, option.value)
}
options={operatorOptions}
width={19}
/>
{filter.operator !== HostTagOperatorValue.Exists &&
filter.operator !== HostTagOperatorValue.DoesNotExist && (
<Input
value={hostTagValueDrafts[index] ?? filter.value}
onChange={(evt: FormEvent<HTMLInputElement>) => {
const value = evt?.currentTarget?.value ?? '';
setHostTagValueDrafts((prevDrafts) => {
const nextDrafts = [...prevDrafts];
nextDrafts[index] = value;
return nextDrafts;
});
}}
onBlur={(evt: FormEvent<HTMLInputElement>) =>
setHostTagFilterValue(index, evt?.currentTarget?.value)
}
width={19}
placeholder="Host tag value"
/>
)}
<Tooltip content="Remove host tag filter">
<Button
key={`remove-host-tag-${index}`}
icon="minus"
variant="secondary"
aria-label="Remove host tag filter"
onClick={() => onRemoveHostTagFilter(index)}
/>
</Tooltip>
</Stack>
);
})}
</Stack>
</div>
);
};

View File

@@ -1,15 +1,16 @@
import _ from 'lodash'; import { flatten, uniqBy } from 'lodash';
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useAsyncFn } from 'react-use'; import { useAsyncFn } from 'react-use';
import { InlineField, ComboboxOption } from '@grafana/ui'; import { InlineField, ComboboxOption } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow'; import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components'; import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils'; import { getVariableOptions, processHostTags } from './utils';
import { ZabbixDatasource } from '../../datasource'; import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types/query'; import { HostTagFilter, ZabbixMetricsQuery, ZabbixTagEvalType } from '../../types/query';
import { ZBXItem, ZBXItemTag } from '../../types'; import { ZBXItem, ZBXItemTag } from '../../types';
import { itemTagToString } from '../../utils'; import { itemTagToString } from '../../utils';
import { HostTagQueryEditor } from './HostTagQueryEditor';
import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery'; import { useInterpolatedQuery } from '../../hooks/useInterpolatedQuery';
export interface Props { export interface Props {
@@ -28,6 +29,9 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
value: group.name, value: group.name,
label: group.name, label: group.name,
})); }));
if (options.length > 0) {
options.unshift({ value: '/.*/' });
}
options.unshift(...getVariableOptions()); options.unshift(...getVariableOptions());
return options; return options;
}; };
@@ -37,22 +41,44 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
return options; return options;
}, []); }, []);
const loadHostOptions = async (group: string) => { const loadHostTagOptions = async (group: string) => {
const hosts = await datasource.zabbix.getAllHosts(group); const hostsWithTags = await datasource.zabbix.getAllHosts(group, true);
const hostTags = processHostTags(hostsWithTags ?? []);
let options: Array<ComboboxOption<string>> = 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<ComboboxOption<string>> = hosts?.map((host) => ({ let options: Array<ComboboxOption<string>> = hosts?.map((host) => ({
value: host.name, value: host.name,
label: host.name, label: host.name,
})); }));
options = _.uniqBy(options, (o) => o.value); options = uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' }); if (options.length > 0) {
options.unshift({ value: '/.*/' });
}
options.unshift(...getVariableOptions()); options.unshift(...getVariableOptions());
return options; return options;
}; };
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => { const [{ loading: hostTagsLoading, value: hostTagsOptions }, fetchHostTags] = useAsyncFn(async () => {
const options = await loadHostOptions(interpolatedQuery.group.filter); const options = await loadHostTagOptions(query.group.filter);
return options; 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 loadAppOptions = async (group: string, host: string) => {
const apps = await datasource.zabbix.getAllApps(group, host); const apps = await datasource.zabbix.getAllApps(group, host);
@@ -60,7 +86,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
value: app.name, value: app.name,
label: app.name, label: app.name,
})); }));
options = _.uniqBy(options, (o) => o.value); options = uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions()); options.unshift(...getVariableOptions());
return options; return options;
}; };
@@ -77,15 +103,15 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
} }
const items = await datasource.zabbix.getAllItems(group, host, 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[] = flatten(items.map((item: ZBXItem) => item.tags || []));
// const tags: ZBXItemTag[] = await datasource.zabbix.getItemTags(groupFilter, hostFilter, null); // 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<ComboboxOption<string>> = tagList?.map((tag) => ({ let options: Array<ComboboxOption<string>> = tagList?.map((tag) => ({
value: tag, value: tag,
label: tag, label: tag,
})); }));
options = _.uniqBy(options, (o) => o.value); options = uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions()); options.unshift(...getVariableOptions());
return options; return options;
}; };
@@ -123,7 +149,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
value: item.name, value: item.name,
label: item.name, label: item.name,
})); }));
itemOptions = _.uniqBy(itemOptions, (o) => o.value); itemOptions = uniqBy(itemOptions, (o) => o.value);
itemOptions.unshift(...getVariableOptions()); itemOptions.unshift(...getVariableOptions());
return itemOptions; return itemOptions;
}; };
@@ -147,6 +173,8 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
// Update suggestions on every metric change // Update suggestions on every metric change
const groupFilter = interpolatedQuery.group?.filter; const groupFilter = interpolatedQuery.group?.filter;
const hostTagFilters = interpolatedQuery.hostTags;
const evalType = interpolatedQuery.evaltype;
const hostFilter = interpolatedQuery.host?.filter; const hostFilter = interpolatedQuery.host?.filter;
const appFilter = interpolatedQuery.application?.filter; const appFilter = interpolatedQuery.application?.filter;
const tagFilter = interpolatedQuery.itemTag?.filter; const tagFilter = interpolatedQuery.itemTag?.filter;
@@ -157,9 +185,13 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchHosts(); fetchHostTags();
}, [groupFilter]); }, [groupFilter]);
useEffect(() => {
fetchHosts();
}, [groupFilter, hostTagFilters, evalType]);
useEffect(() => { useEffect(() => {
fetchApps(); fetchApps();
}, [groupFilter, hostFilter]); }, [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(); const supportsApplications = datasource.zabbix.supportsApplications();
return ( return (
@@ -195,6 +241,16 @@ export const MetricsQueryEditor = ({ query, datasource, onChange, onItemCountCha
placeholder="Group name" placeholder="Group name"
/> />
</InlineField> </InlineField>
<InlineField label="Host tag" labelWidth={12}>
<HostTagQueryEditor
hostTagOptions={hostTagsOptions}
evalTypeValue={query.evaltype}
hostTagOptionsLoading={hostTagsLoading}
onHostTagFilterChange={onHostTagFilterChange}
onHostTagEvalTypeChange={onHostTagEvalTypeChange}
version={datasource.zabbix.version}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}> <InlineField label="Host" labelWidth={12}>
<MetricPicker <MetricPicker
width={24} width={24}

View File

@@ -0,0 +1,23 @@
export enum HostTagOperatorLabel {
Exists = 'Exists',
Equals = 'Equals',
Contains = 'Contains',
DoesNotExist = 'Does not exist',
DoesNotEqual = 'Does not equal',
DoesNotContain = 'Does not contain',
}
export enum HostTagOperatorValue {
Contains = '0', // default
Equals = '1',
DoesNotContain = '2',
DoesNotEqual = '3',
Exists = '4',
DoesNotExist = '5',
}
export enum HostTagOperatorLabelBefore70 {
NotExist = 'Not exists',
NotEqual = 'Not equal',
NotLike = 'Not like',
}

View File

@@ -0,0 +1,89 @@
import { getTemplateSrv } from '@grafana/runtime';
import { getHostTagOptionLabel, getVariableOptions, processHostTags } from './utils';
import { HostTagOperatorLabel, HostTagOperatorLabelBefore70, HostTagOperatorValue } from './types';
jest.mock(
'@grafana/runtime',
() => ({
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('');
});
});
});

View File

@@ -1,4 +1,7 @@
import { uniqBy } from 'lodash';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { Host, Tag } from 'datasource/zabbix/types';
import { HostTagOperatorLabel, HostTagOperatorLabelBefore70, HostTagOperatorValue } from './types';
export const getVariableOptions = () => { export const getVariableOptions = () => {
const variables = getTemplateSrv() const variables = getTemplateSrv()
@@ -11,3 +14,28 @@ export const getVariableOptions = () => {
label: `$${v.name}`, 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 '';
}
}

View File

@@ -1,5 +1,6 @@
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import * as c from './../constants'; import * as c from './../constants';
import { HostTagOperatorValue } from 'datasource/components/QueryEditor/types';
export type QueryType = export type QueryType =
| typeof c.MODE_METRICS | typeof c.MODE_METRICS
@@ -24,6 +25,7 @@ export type ZabbixMetricsQuery = {
mode: number; mode: number;
itemids: string; itemids: string;
useCaptureGroups: boolean; useCaptureGroups: boolean;
hostTags?: HostTagFilter[];
proxy?: { filter: string }; proxy?: { filter: string };
trigger?: { filter: string }; trigger?: { filter: string };
itServiceFilter?: string; itServiceFilter?: string;
@@ -108,3 +110,9 @@ export enum ZabbixTagEvalType {
AndOr = '0', AndOr = '0',
Or = '2', Or = '2',
} }
export interface HostTagFilter {
tag: string;
value: string;
operator: HostTagOperatorValue;
}

View File

@@ -1,4 +1,6 @@
import { ZabbixAPIConnector } from './zabbixAPIConnector'; import { ZabbixAPIConnector } from './zabbixAPIConnector';
import { HostTagOperatorValue } from '../../../components/QueryEditor/types';
import { ZabbixTagEvalType } from 'datasource/types/query';
describe('Zabbix API connector', () => { describe('Zabbix API connector', () => {
describe('getProxies function', () => { describe('getProxies function', () => {
@@ -154,6 +156,80 @@ describe('Zabbix API connector', () => {
expect(params.applicationids).toBeUndefined(); 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 = [ const triggers = [

View File

@@ -3,7 +3,7 @@ import semver from 'semver';
import kbn from 'grafana/app/core/utils/kbn'; import kbn from 'grafana/app/core/utils/kbn';
import * as utils from '../../../utils'; import * as utils from '../../../utils';
import { MIN_SLA_INTERVAL, ZBX_ACK_ACTION_ADD_MESSAGE, ZBX_ACK_ACTION_NONE } from '../../../constants'; 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 { ZBXProblem, ZBXTrigger } from '../../../types';
import { APIExecuteScriptResponse, JSONRPCError, ZBXScript } from './types'; import { APIExecuteScriptResponse, JSONRPCError, ZBXScript } from './types';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
@@ -149,7 +149,12 @@ export class ZabbixAPIConnector {
return this.request('hostgroup.get', params); return this.request('hostgroup.get', params);
} }
getHosts(groupids): Promise<any[]> { getHosts(
groupids: string[],
getHostTags?: boolean,
hostTagFilters?: HostTagFilter[],
evalType?: ZabbixTagEvalType
): Promise<any[]> {
const params: any = { const params: any = {
output: ['hostid', 'name', 'host'], output: ['hostid', 'name', 'host'],
sortfield: 'name', sortfield: 'name',
@@ -158,6 +163,23 @@ export class ZabbixAPIConnector {
params.groupids = groupids; 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); return this.request('host.get', params);
} }

View File

@@ -50,3 +50,15 @@ export interface ZabbixConnector {
supportsApplications: () => boolean; supportsApplications: () => boolean;
} }
export interface Host {
host: string;
name: string;
hostid?: string;
tags?: Tag[];
}
export interface Tag {
tag: string;
value: string;
}

View File

@@ -9,9 +9,9 @@ import { DBConnector } from './connectors/dbConnector';
import { ZabbixAPIConnector } from './connectors/zabbix_api/zabbixAPIConnector'; import { ZabbixAPIConnector } from './connectors/zabbix_api/zabbixAPIConnector';
import { SQLConnector } from './connectors/sql/sqlConnector'; import { SQLConnector } from './connectors/sql/sqlConnector';
import { InfluxDBConnector } from './connectors/influxdb/influxdbConnector'; import { InfluxDBConnector } from './connectors/influxdb/influxdbConnector';
import { ZabbixConnector } from './types'; import { Host, ZabbixConnector } from './types';
import { joinTriggersWithEvents, joinTriggersWithProblems } from '../problemsHandler'; 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'; import { ProblemDTO, ZBXApp, ZBXHost, ZBXItem, ZBXItemTag, ZBXTrigger } from '../types';
interface AppsResponse extends Array<any> { interface AppsResponse extends Array<any> {
@@ -296,6 +296,7 @@ export class Zabbix implements ZabbixConnector {
} }
getAllGroups() { getAllGroups() {
console.log(this.zabbixAPI.getGroups());
return this.zabbixAPI.getGroups(); return this.zabbixAPI.getGroups();
} }
@@ -306,10 +307,15 @@ export class Zabbix implements ZabbixConnector {
/** /**
* Get list of host belonging to given groups. * Get list of host belonging to given groups.
*/ */
getAllHosts(groupFilter): Promise<any[]> { getAllHosts(
groupFilter: string,
getHostTags?: boolean,
hostTagFilters?: HostTagFilter[],
evalType?: ZabbixTagEvalType
): Promise<Host[]> {
return this.getGroups(groupFilter).then((groups) => { return this.getGroups(groupFilter).then((groups) => {
const groupids = _.map(groups, 'groupid'); const groupids = _.map(groups, 'groupid');
return this.zabbixAPI.getHosts(groupids); return this.zabbixAPI.getHosts(groupids, getHostTags, hostTagFilters, evalType);
}); });
} }