Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-fans-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grafana-zabbix': minor
---

Add support for host tags when querying metrics
10 changes: 5 additions & 5 deletions pkg/zabbix/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions pkg/zabbix/methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down
11 changes: 6 additions & 5 deletions pkg/zabbix/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ 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 {
ID string `json:"hostid,omitempty"`
Name string `json:"name,omitempty"`
}

type ItemTag struct {
type Tag struct {
Tag string `json:"tag,omitempty"`
Value string `json:"value,omitempty"`
}
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions pkg/zabbix/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,16 @@ 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 {
return tag.Tag
}
}

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])
Expand Down
137 changes: 137 additions & 0 deletions src/datasource/components/QueryEditor/HostTagQueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
}

export const HostTagQueryEditor = ({
hostTagOptions,
hostTagOptionsLoading,
version,
evalTypeValue,
onHostTagFilterChange,
onHostTagEvalTypeChange,
}: Props) => {
const [hostTagFilters, setHostTagFilters] = useState<HostTagFilter[]>([]);
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 },
]);
}, []);

const onRemoveHostTagFilter = useCallback((index: number) => {
setHostTagFilters((prevFilters) => prevFilters.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 (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return (
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={filter.value}
onChange={(evt: FormEvent<HTMLInputElement>) =>
setHostTagFilterValue(index, evt?.currentTarget?.value)
}
width={19}
/>
Comment on lines +114 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A short placeholder would be useful. At first glance I've missed what I need to do with the input field.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my each keystroke it sends another request to the backend. Can we have a debounce logic?

)}
<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>
);
};
72 changes: 62 additions & 10 deletions src/datasource/components/QueryEditor/MetricsQueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import _ from 'lodash';
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useAsyncFn } from 'react-use';

import { SelectableValue } from '@grafana/data';
import { InlineField } from '@grafana/ui';
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';
import { SelectableValue } from '@grafana/data';

export interface Props {
query: ZabbixMetricsQuery;
Expand All @@ -28,6 +29,7 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
value: group.name,
label: group.name,
}));
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
Expand All @@ -37,8 +39,18 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
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<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<SelectableValue<string>> = hosts?.map((host) => ({
value: host.name,
label: host.name,
Expand All @@ -49,10 +61,20 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
return options;
};

const [{ loading: hostTagsLoading, value: hostTagsOptions }, fetchHostTags] = useAsyncFn(async () => {
const options = await loadHostTagOptions(query.group.filter);
return options;
}, [query.group.filter]);

const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(interpolatedQuery.group.filter);
const options = await loadHostOptions(
interpolatedQuery.group.filter,
interpolatedQuery.hostTags,
interpolatedQuery.evaltype
);

return options;
}, [interpolatedQuery.group.filter]);
}, [interpolatedQuery.group.filter, interpolatedQuery.hostTags, interpolatedQuery.evaltype]);

const loadAppOptions = async (group: string, host: string) => {
const apps = await datasource.zabbix.getAllApps(group, host);
Expand Down Expand Up @@ -127,6 +149,8 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {

// 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;
Expand All @@ -136,9 +160,13 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
}, []);

useEffect(() => {
fetchHosts();
fetchHostTags();
}, [groupFilter]);

useEffect(() => {
fetchHosts();
}, [groupFilter, hostTagFilters, evalType]);

useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
Expand All @@ -159,6 +187,20 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
};
};

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 (
Expand All @@ -173,6 +215,16 @@ export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange={onFilterChange('group')}
/>
</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}>
<MetricPicker
width={24}
Expand Down
Loading
Loading