Migrate query editor to react (#1520)

* Initial react query editor

* CI: run checks on all branches

* Update react packages

* Initial metric picker

* Load metrics

* Tweak styles

* Add variables to metric options

* Tweak styles

* Filtering and keyboard navigation

* Open menu with keyboard

* Update function editor

* Move functions in editor

* Add function component

* Edit func params

* Push alias functions to the end

* Tweak labels size

* Fix menu position

* Metric options editor

* Fix css styles building

* More work on query options

* Fix tests

* Refactor: extract metrics query editor and functions editor

* Refactor: move things around

* Text metrics editor

* Problems query editor

* Problems mode options

* Item id query editor

* IT services query editor

* Triggers query editor

* Refactor: remove unused

* remove derprecated theme usage

* Load proxy options

* Fetch metric options on variable change

* Remove angular query editor

* Migrate annotations editor to react

* Fix tests
This commit is contained in:
Alexander Zobnin
2022-11-09 17:50:13 +03:00
committed by GitHub
parent f765d47fed
commit 504c9af226
44 changed files with 7822 additions and 5868 deletions

View File

@@ -2,8 +2,6 @@ name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
@@ -16,12 +14,12 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v2.1.2
with:
node-version: "16.x"
node-version: '16.x'
- name: Setup Go environment
uses: actions/setup-go@v2
with:
go-version: "1.17"
go-version: '1.17'
- name: Get yarn cache directory path
id: yarn-cache-dir-path
@@ -61,12 +59,12 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v2.1.2
with:
node-version: "16.x"
node-version: '16.x'
- name: Setup Go environment
uses: actions/setup-go@v2
with:
go-version: "1.17"
go-version: '1.17'
- name: Get yarn cache directory path
id: yarn-cache-dir-path
@@ -105,12 +103,12 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v2.1.2
with:
node-version: "16.x"
node-version: '16.x'
- name: Setup Go environment
uses: actions/setup-go@v2
with:
go-version: "1.17"
go-version: '1.17'
- name: Get yarn cache directory path
id: yarn-cache-dir-path

View File

@@ -30,18 +30,18 @@
"@babel/preset-react": "7.6.3",
"@emotion/css": "11.1.3",
"@emotion/react": "11.1.5",
"@grafana/data": "^8.3.6",
"@grafana/runtime": "^8.3.6",
"@grafana/toolkit": "^8.3.6",
"@grafana/ui": "^8.3.6",
"@grafana/data": "9.1.2",
"@grafana/runtime": "9.1.2",
"@grafana/toolkit": "9.1.2",
"@grafana/ui": "9.1.2",
"@popperjs/core": "2.4.0",
"@types/classnames": "2.2.9",
"@types/grafana": "github:CorpGlory/types-grafana",
"@types/jest": "24.0.13",
"@types/jquery": "3.3.32",
"@types/lodash": "4.14.161",
"@types/react": "16.8.16",
"@types/react-dom": "16.8.4",
"@types/react": "17.0.42",
"@types/react-dom": "17.0.14",
"@types/react-transition-group": "4.2.4",
"axios": "^0.21.1",
"babel-jest": "24.8.0",
@@ -69,12 +69,13 @@
"ng-annotate-webpack-plugin": "0.3.0",
"node-sass": "^7.0.1",
"prop-types": "15.7.2",
"react": "16.12.0",
"react-dom": "16.12.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-popper": "^2.2.3",
"react-table-6": "^6.8.6",
"react-test-renderer": "^16.7.0",
"react-test-renderer": "17.0.2",
"react-transition-group": "4.3.0",
"react-use": "17.4.0",
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "6.6.3",
"sass-loader": "10.2.1",

View File

@@ -0,0 +1,141 @@
import { css, cx } from '@emotion/css';
import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react';
import { ClickOutsideWrapper, Icon, Input, Spinner, useStyles2 } from '@grafana/ui';
import { MetricPickerMenu } from './MetricPickerMenu';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { isRegex } from '../../datasource-zabbix/utils';
export interface Props {
value: string;
isLoading?: boolean;
options: SelectableValue<string>[];
width?: number;
onChange: (value: string) => void;
}
export const MetricPicker = ({ value, options, isLoading, width, onChange }: Props): JSX.Element => {
const [isOpen, setOpen] = useState(false);
const [query, setQuery] = useState(value);
const [filteredOptions, setFilteredOptions] = useState(options);
const [selectedOptionIdx, setSelectedOptionIdx] = useState(-1);
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const ref = useRef<HTMLDivElement>(null);
const customStyles = useStyles2(getStyles);
const inputClass = cx({
[customStyles.inputRegexp]: isRegex(query),
[customStyles.inputVariable]: query.startsWith('$'),
});
useEffect(() => {
setFilteredOptions(options);
}, [options]);
const onOpen = () => {
setOpen(true);
setFilteredOptions(options);
};
const onClose = useCallback(() => {
setOpen(false);
}, []);
// Only call onClose if menu is open. Prevent unnecessary calls for multiple pickers on the page.
const onClickOutside = () => isOpen && onClose();
const onInputChange = (v: FormEvent<HTMLInputElement>) => {
if (!isOpen) {
setOpen(true);
}
const newQuery = v?.currentTarget?.value;
if (newQuery) {
setQuery(newQuery);
if (value != newQuery) {
const filtered = options.filter(
(option) =>
option.value?.toLowerCase().includes(newQuery.toLowerCase()) ||
option.label?.toLowerCase().includes(newQuery.toLowerCase())
);
setFilteredOptions(filtered);
} else {
setFilteredOptions(options);
}
} else {
setQuery('');
setFilteredOptions(options);
}
};
const onMenuOptionSelect = (option: SelectableValue<string>) => {
const newValue = option?.value || '';
setQuery(newValue);
onChange(newValue);
onClose();
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
if (!isOpen) {
setOpen(true);
}
e.preventDefault();
e.stopPropagation();
const selected = selectedOptionIdx < filteredOptions.length - 1 ? selectedOptionIdx + 1 : 0;
setSelectedOptionIdx(selected);
} else if (e.key === 'ArrowUp') {
if (!isOpen) {
setOpen(true);
}
e.preventDefault();
e.stopPropagation();
const selected = selectedOptionIdx > 0 ? selectedOptionIdx - 1 : filteredOptions.length - 1;
setSelectedOptionIdx(selected);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onMenuOptionSelect(filteredOptions[selectedOptionIdx]);
}
};
return (
<div data-testid="role-picker" style={{ position: 'relative' }} ref={ref}>
<ClickOutsideWrapper onClick={onClickOutside}>
<Input
className={inputClass}
value={query}
type="text"
onChange={onInputChange}
onBlur={() => onChange(query)}
onMouseDown={onOpen}
suffix={isLoading && <Spinner />}
width={width}
onKeyDown={onKeyDown}
/>
{isOpen && (
<MetricPickerMenu
options={filteredOptions}
onSelect={onMenuOptionSelect}
offset={offset}
minWidth={width}
selected={selectedOptionIdx}
/>
)}
</ClickOutsideWrapper>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
inputRegexp: css`
input {
color: ${theme.colors.warning.main};
}
`,
inputVariable: css`
input {
color: ${theme.colors.primary.text};
}
`,
};
};

View File

@@ -0,0 +1,138 @@
import { css, cx } from '@emotion/css';
import React, { FormEvent } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { CustomScrollbar, getSelectStyles, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { MENU_MAX_HEIGHT } from './constants';
interface Props {
options: SelectableValue<string>[];
onSelect: (option: SelectableValue<string>) => void;
offset: { vertical: number; horizontal: number };
minWidth?: number;
selected?: number;
}
export const MetricPickerMenu = ({ options, offset, minWidth, selected, onSelect }: Props): JSX.Element => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles(minWidth));
return (
<div
className={cx(
styles.menu,
customStyles.menuWrapper,
{ [customStyles.menuLeft]: offset.horizontal > 0 },
css`
bottom: ${offset.vertical > 0 ? `${offset.vertical}px` : 'unset'};
top: ${offset.vertical < 0 ? `${Math.abs(offset.vertical)}px` : 'unset'};
`
)}
>
<div className={customStyles.menu} aria-label="Metric picker menu">
<CustomScrollbar autoHide={false} autoHeightMax={`${MENU_MAX_HEIGHT}px`} hideHorizontalTrack>
<div>
<div className={styles.optionBody}>
{options?.map((option, i) => (
<MenuOption data={option} key={i} onClick={onSelect} isFocused={selected === i} hideDescription />
))}
</div>
</div>
</CustomScrollbar>
</div>
</div>
);
};
interface MenuOptionProps<T> {
data: SelectableValue<string>;
onClick: (value: SelectableValue<string>) => void;
isFocused?: boolean;
disabled?: boolean;
hideDescription?: boolean;
}
const MenuOption = React.forwardRef<HTMLDivElement, React.PropsWithChildren<MenuOptionProps<any>>>(
({ data, isFocused, disabled, onClick, hideDescription }, ref) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles());
const wrapperClassName = cx(
styles.option,
customStyles.menuOptionWrapper,
isFocused && styles.optionFocused,
disabled && customStyles.menuOptionDisabled
);
const onClickInternal = (event: FormEvent<HTMLElement>) => {
if (disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick(data);
};
return (
<div ref={ref} className={wrapperClassName} aria-label="Menu option" onClick={onClickInternal}>
<div className={cx(styles.optionBody, customStyles.menuOptionBody)}>
<span>{data.label || data.value}</span>
{!hideDescription && data.description && <div className={styles.optionDescription}>{data.description}</div>}
</div>
{data.description && (
<Tooltip content={data.description}>
<Icon name="info-circle" className={customStyles.menuOptionInfoSign} />
</Tooltip>
)}
</div>
);
}
);
MenuOption.displayName = 'MenuOption';
export const getStyles = (menuWidth?: number) => (theme: GrafanaTheme2) => {
return {
menuWrapper: css`
display: flex;
max-height: 650px;
position: absolute;
z-index: ${theme.zIndex.dropdown};
overflow: hidden;
min-width: auto;
`,
menu: css`
min-width: ${theme.spacing(menuWidth || 0)};
& > div {
padding-top: ${theme.spacing(1)};
}
`,
menuLeft: css`
right: 0;
flex-direction: row-reverse;
`,
container: css`
padding: ${theme.spacing(1)};
border: 1px ${theme.colors.border.weak} solid;
border-radius: ${theme.shape.borderRadius(1)};
background-color: ${theme.colors.background.primary};
z-index: ${theme.zIndex.modal};
`,
menuOptionWrapper: css`
padding: ${theme.spacing(0.5)};
`,
menuOptionBody: css`
font-weight: ${theme.typography.fontWeightRegular};
padding: ${theme.spacing(0, 1.5, 0, 0)};
`,
menuOptionDisabled: css`
color: ${theme.colors.text.disabled};
cursor: not-allowed;
`,
menuOptionInfoSign: css`
color: ${theme.colors.text.disabled};
`,
};
};

View File

@@ -0,0 +1,2 @@
export const MENU_MAX_HEIGHT = 300; // max height for the picker's dropdown menu
export const METRIC_PICKER_WIDTH = 360;

View File

@@ -4,3 +4,4 @@ export { AckButton } from './AckButton/AckButton';
export { ExploreButton } from './ExploreButton/ExploreButton';
export { ExecScriptButton } from './ExecScriptButton/ExecScriptButton';
export { ModalController } from './Modal/ModalController';
export { MetricPicker } from './MetricPicker/MetricPicker';

View File

@@ -1,106 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import * as metricFunctions from './metricFunctions';
angular
.module('grafana.directives')
.directive('addMetricFunction',
/** @ngInject */
function($compile) {
var inputTemplate = '<input type="text"'+
' class="gf-form-input"' +
' spellcheck="false" style="display:none"></input>';
var buttonTemplate = '<a class="gf-form-label tight-form-func dropdown-toggle query-part"' +
' tabindex="1" gf-dropdown="functionMenu" data-toggle="dropdown">' +
'<i class="fa fa-plus"></i></a>';
return {
link: function($scope, elem) {
var categories = metricFunctions.getCategories();
var allFunctions = getAllFunctionNames(categories);
$scope.functionMenu = createFunctionDropDownMenu(categories);
var $input = $(inputTemplate);
var $button = $(buttonTemplate);
$input.appendTo(elem);
$button.appendTo(elem);
$input.attr('data-provide', 'typeahead');
$input.typeahead({
source: allFunctions,
minLength: 1,
items: 10,
updater: function (value) {
var funcDef = metricFunctions.getFuncDef(value);
if (!funcDef) {
// try find close match
value = value.toLowerCase();
funcDef = _.find(allFunctions, function(funcName) {
return funcName.toLowerCase().indexOf(value) === 0;
});
if (!funcDef) { return; }
}
$scope.$apply(function() {
$scope.ctrl.addFunction(funcDef);
});
$input.trigger('blur');
return '';
}
});
$button.click(function() {
$button.hide();
$input.show();
$input.focus();
});
$input.keyup(function() {
elem.toggleClass('open', $input.val() === '');
});
$input.blur(function() {
// clicking the function dropdown menu won't
// work if you remove class at once
setTimeout(function() {
$input.val('');
$input.hide();
$button.show();
elem.removeClass('open');
}, 200);
});
$compile(elem.contents())($scope);
}
};
});
function getAllFunctionNames(categories) {
return _.reduce(categories, function(list, category) {
_.each(category, function(func) {
list.push(func.name);
});
return list;
}, []);
}
function createFunctionDropDownMenu(categories) {
return _.map(categories, function(list, category) {
return {
text: category,
submenu: _.map(list, function(value) {
return {
text: value.name,
click: "ctrl.addFunction('" + value.name + "')",
};
})
};
});
}

View File

@@ -0,0 +1,195 @@
import _ from 'lodash';
import React, { useEffect, FormEvent } from 'react';
import { useAsyncFn } from 'react-use';
import { AnnotationQuery, SelectableValue } from '@grafana/data';
import { InlineField, InlineSwitch, Input, Select } from '@grafana/ui';
import { ZabbixMetricsQuery } from '../types';
import { ZabbixQueryEditorProps } from './QueryEditor';
import { QueryEditorRow } from './QueryEditor/QueryEditorRow';
import { MetricPicker } from '../../components';
import { getVariableOptions } from './QueryEditor/utils';
import { prepareAnnotation } from '../migrations';
const severityOptions: SelectableValue<number>[] = [
{ value: 0, label: 'Not classified' },
{ value: 1, label: 'Information' },
{ value: 2, label: 'Warning' },
{ value: 3, label: 'Average' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Disaster' },
];
type Props = ZabbixQueryEditorProps & {
annotation?: AnnotationQuery<ZabbixMetricsQuery>;
onAnnotationChange?: (annotation: AnnotationQuery<ZabbixMetricsQuery>) => void;
};
export const AnnotationQueryEditor = ({ annotation, onAnnotationChange, datasource }: Props) => {
annotation = prepareAnnotation(annotation);
const query = annotation.target;
const loadGroupOptions = async () => {
const groups = await datasource.zabbix.getAllGroups();
const options = groups?.map((group) => ({
value: group.name,
label: group.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: groupsLoading, value: groupsOptions }, fetchGroups] = useAsyncFn(async () => {
const options = await loadGroupOptions();
return options;
}, []);
const loadHostOptions = async (group: string) => {
const groupFilter = datasource.replaceTemplateVars(group);
const hosts = await datasource.zabbix.getAllHosts(groupFilter);
let options: SelectableValue<string>[] = hosts?.map((host) => ({
value: host.name,
label: host.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(query.group.filter);
return options;
}, [query.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);
let options: SelectableValue<string>[] = apps?.map((app) => ({
value: app.name,
label: app.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => {
const options = await loadAppOptions(query.group.filter, query.host.filter);
return options;
}, [query.group.filter, query.host.filter]);
// Update suggestions on every metric change
const groupFilter = datasource.replaceTemplateVars(query.group?.filter);
const hostFilter = datasource.replaceTemplateVars(query.host?.filter);
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
fetchHosts();
}, [groupFilter]);
useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
const onChange = (query: any) => {
onAnnotationChange({
...annotation,
target: query,
});
};
const onFilterChange = (prop: string) => {
return (value: string) => {
if (value !== null) {
onChange({ ...query, [prop]: { filter: value } });
}
};
};
const onTextFilterChange = (prop: string) => {
return (v: FormEvent<HTMLInputElement>) => {
const newValue = v?.currentTarget?.value;
if (newValue !== null) {
onChange({ ...query, [prop]: { filter: newValue } });
}
};
};
const onMinSeverityChange = (option: SelectableValue) => {
if (option.value !== null) {
onChange({ ...query, options: { ...query.options, minSeverity: option.value } });
}
};
const onOptionSwitch = (prop: string) => () => {
onChange({ ...query, options: { ...query.options, [prop]: !query.options[prop] } });
};
return (
<>
<QueryEditorRow>
<InlineField label="Group" labelWidth={12}>
<MetricPicker
width={24}
value={query.group?.filter}
options={groupsOptions}
isLoading={groupsLoading}
onChange={onFilterChange('group')}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}>
<MetricPicker
width={24}
value={query.host?.filter}
options={hostOptions}
isLoading={hostsLoading}
onChange={onFilterChange('host')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Application" labelWidth={12}>
<MetricPicker
width={24}
value={query.application?.filter}
options={appOptions}
isLoading={appsLoading}
onChange={onFilterChange('application')}
/>
</InlineField>
<InlineField label="Problem" labelWidth={12}>
<Input
width={24}
defaultValue={query.trigger?.filter}
placeholder="Problem name"
onBlur={onTextFilterChange('trigger')}
/>
</InlineField>
</QueryEditorRow>
<>
<InlineField label="Min severity" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.options?.minSeverity}
options={severityOptions}
onChange={onMinSeverityChange}
/>
</InlineField>
<InlineField label="Show OK events" labelWidth={24}>
<InlineSwitch value={query.options.showOkEvents} onChange={onOptionSwitch('showOkEvents')} />
</InlineField>
<InlineField label="Hide acknowledged events" labelWidth={24}>
<InlineSwitch value={query.options.hideAcknowledged} onChange={onOptionSwitch('hideAcknowledged')} />
</InlineField>
<InlineField label="Show hostname" labelWidth={24}>
<InlineSwitch value={query.options.showHostname} onChange={onOptionSwitch('showHostname')} />
</InlineField>
</>
</>
);
};

View File

@@ -1,108 +0,0 @@
import React from 'react';
// import rst2html from 'rst2html';
import { FunctionDescriptor, FunctionEditorControlsProps, FunctionEditorControls } from './FunctionEditorControls';
// @ts-ignore
import { PopoverController, Popover } from '@grafana/ui';
interface FunctionEditorProps extends FunctionEditorControlsProps {
func: FunctionDescriptor;
}
interface FunctionEditorState {
showingDescription: boolean;
}
class FunctionEditor extends React.PureComponent<FunctionEditorProps, FunctionEditorState> {
private triggerRef = React.createRef<HTMLSpanElement>();
constructor(props: FunctionEditorProps) {
super(props);
this.state = {
showingDescription: false,
};
}
renderContent = ({ updatePopperPosition }) => {
const {
onMoveLeft,
onMoveRight,
func: {
def: { name, description },
},
} = this.props;
const { showingDescription } = this.state;
if (showingDescription) {
return (
<div style={{ overflow: 'auto', maxHeight: '30rem', textAlign: 'left', fontWeight: 'normal' }}>
<h4 style={{ color: 'white' }}> {name} </h4>
<div>{description}</div>
</div>
);
}
return (
<FunctionEditorControls
{...this.props}
onMoveLeft={() => {
onMoveLeft(this.props.func);
updatePopperPosition();
}}
onMoveRight={() => {
onMoveRight(this.props.func);
updatePopperPosition();
}}
onDescriptionShow={() => {
this.setState({ showingDescription: true }, () => {
updatePopperPosition();
});
}}
/>
);
};
render() {
return (
<PopoverController content={this.renderContent} placement="top" hideAfter={300}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{this.triggerRef && (
<Popover
{...popperProps}
referenceElement={this.triggerRef.current}
wrapperClassName="popper"
className="popper__background"
onMouseLeave={() => {
this.setState({ showingDescription: false });
hidePopper();
}}
onMouseEnter={showPopper}
renderArrow={({ arrowProps, placement }) => (
<div className="popper__arrow" data-placement={placement} {...arrowProps} />
)}
/>
)}
<span
ref={this.triggerRef}
onClick={popperProps.show ? hidePopper : showPopper}
onMouseLeave={() => {
hidePopper();
this.setState({ showingDescription: false });
}}
style={{ cursor: 'pointer' }}
>
{this.props.func.def.name}
</span>
</>
);
}}
</PopoverController>
);
}
}
export { FunctionEditor };

View File

@@ -0,0 +1,88 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Button,
ClickOutsideWrapper,
ContextMenu,
Dropdown,
Icon,
Input,
Menu,
MenuItem,
Portal,
Segment,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { FuncDef } from '../../types';
import { getCategories } from '../../metricFunctions';
// import { mapFuncDefsToSelectables } from './helpers';
type Props = {
// funcDefs: MetricFunc;
onFuncAdd: (def: FuncDef) => void;
};
export function AddZabbixFunction({ onFuncAdd }: Props) {
const [showMenu, setShowMenu] = useState(false);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const onFuncAddInternal = (def: FuncDef) => {
onFuncAdd(def);
setShowMenu(false);
};
const onSearch = (e: React.FormEvent<HTMLInputElement>) => {
console.log(e.currentTarget.value);
};
const onClickOutside = () => {
setShowMenu(false);
};
const menuItems = useMemo(() => buildMenuItems(onFuncAddInternal), [onFuncAdd]);
return (
<div>
{!showMenu && (
<Button
icon="plus"
variant="secondary"
className={cx(styles.button)}
aria-label="Add new function"
onClick={() => setShowMenu(!showMenu)}
/>
)}
{showMenu && (
<ClickOutsideWrapper onClick={onClickOutside} useCapture>
<Input onChange={onSearch} suffix={<Icon name="search" />} />
<Menu style={{ position: 'absolute', zIndex: theme.zIndex.dropdown }}>{menuItems}</Menu>
</ClickOutsideWrapper>
)}
</div>
);
}
function buildMenuItems(onClick: (func: FuncDef) => void) {
const categories = getCategories();
const menuItems: JSX.Element[] = [];
for (const categoryName in categories) {
const functions = categories[categoryName];
const subItems = functions.map((f) => <Menu.Item label={f.name} key={f.name} onClick={() => onClick(f)} />);
menuItems.push(<Menu.Item label={categoryName} key={categoryName} childItems={subItems} />);
}
return menuItems;
}
function getStyles(theme: GrafanaTheme2) {
return {
button: css`
margin-right: ${theme.spacing(0.5)};
`,
};
}

View File

@@ -0,0 +1,50 @@
import { css } from '@emotion/css';
import React from 'react';
import { FunctionEditorControlsProps, FunctionEditorControls } from './FunctionEditorControls';
import { useStyles2, Tooltip } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { MetricFunc } from '../../types';
interface FunctionEditorProps extends FunctionEditorControlsProps {
func: MetricFunc;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
icon: css`
margin-right: ${theme.spacing(0.5)};
`,
label: css({
fontWeight: theme.typography.fontWeightMedium,
fontSize: theme.typography.bodySmall.fontSize, // to match .gf-form-label
cursor: 'pointer',
display: 'inline-block',
}),
};
};
export const FunctionEditor: React.FC<FunctionEditorProps> = ({ onMoveLeft, onMoveRight, func, ...props }) => {
const styles = useStyles2(getStyles);
const renderContent = ({ updatePopperPosition }: any) => (
<FunctionEditorControls
{...props}
func={func}
onMoveLeft={() => {
onMoveLeft(func);
updatePopperPosition();
}}
onMoveRight={() => {
onMoveRight(func);
updatePopperPosition();
}}
/>
);
return (
<Tooltip content={renderContent} placement="top" interactive>
<span className={styles.label}>{func.def.name}</span>
</Tooltip>
);
};

View File

@@ -0,0 +1,68 @@
import React, { Suspense } from 'react';
import { Icon, Tooltip } from '@grafana/ui';
import { MetricFunc } from '../../types';
const DOCS_FUNC_REF_URL = 'https://alexanderzobnin.github.io/grafana-zabbix/reference/functions/';
export interface FunctionEditorControlsProps {
onMoveLeft: (func: MetricFunc) => void;
onMoveRight: (func: MetricFunc) => void;
onRemove: (func: MetricFunc) => void;
}
const FunctionDescription = React.lazy(async () => {
// @ts-ignore
const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html');
return {
default(props: { description?: string }) {
return <div dangerouslySetInnerHTML={{ __html: rst2html(props.description ?? '') }} />;
},
};
});
const FunctionHelpButton = (props: { description?: string; name: string }) => {
if (props.description) {
let tooltip = (
<Suspense fallback={<span>Loading description...</span>}>
<FunctionDescription description={props.description} />
</Suspense>
);
return (
<Tooltip content={tooltip} placement={'bottom-end'}>
<Icon className={props.description ? undefined : 'pointer'} name="question-circle" />
</Tooltip>
);
}
return (
<Icon
className="pointer"
name="question-circle"
onClick={() => {
window.open(`${DOCS_FUNC_REF_URL}#${props.name}`, '_blank');
}}
/>
);
};
export const FunctionEditorControls = (
props: FunctionEditorControlsProps & {
func: MetricFunc;
}
) => {
const { func, onMoveLeft, onMoveRight, onRemove } = props;
return (
<div
style={{
display: 'flex',
width: '60px',
justifyContent: 'space-between',
}}
>
<Icon className="pointer" name="arrow-left" onClick={() => onMoveLeft(func)} />
<FunctionHelpButton name={func.def.name} description={func.def.description} />
<Icon className="pointer" name="times" onClick={() => onRemove(func)} />
<Icon className="pointer" name="arrow-right" onClick={() => onMoveRight(func)} />
</div>
);
};

View File

@@ -0,0 +1,77 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Segment, SegmentInput, useStyles2 } from '@grafana/ui';
export type EditableParam = {
name: string;
value: string;
optional: boolean;
multiple: boolean;
options: Array<SelectableValue<string>>;
};
type FieldEditorProps = {
editableParam: EditableParam;
onChange: (value: string) => void;
onExpandedChange: (expanded: boolean) => void;
autofocus: boolean;
};
/**
* Render a function parameter with a segment dropdown for multiple options or simple input.
*/
export function FunctionParamEditor({ editableParam, onChange, onExpandedChange, autofocus }: FieldEditorProps) {
const styles = useStyles2(getStyles);
if (editableParam.options?.length > 0) {
return (
<Segment
autofocus={autofocus}
value={editableParam.value}
inputPlaceholder={editableParam.name}
className={styles.segment}
options={editableParam.options}
placeholder={' +' + editableParam.name}
onChange={(value) => {
onChange(value.value || '');
}}
onExpandedChange={onExpandedChange}
inputMinWidth={150}
allowCustomValue={true}
allowEmptyValue={true}
></Segment>
);
} else {
return (
<SegmentInput
autofocus={autofocus}
className={styles.input}
value={editableParam.value || ''}
placeholder={' +' + editableParam.name}
inputPlaceholder={editableParam.name}
onChange={(value) => {
onChange(value.toString());
}}
onExpandedChange={onExpandedChange}
// input style
style={{ height: '25px', paddingTop: '2px', marginTop: '2px', paddingLeft: '4px', minWidth: '100px' }}
></SegmentInput>
);
}
}
const getStyles = (theme: GrafanaTheme2) => ({
segment: css({
margin: 0,
padding: 0,
}),
input: css`
margin: 0;
padding: 0;
input {
height: 25px;
},
`,
});

View File

@@ -0,0 +1,90 @@
import { css, cx } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { HorizontalGroup, InlineLabel, useStyles2 } from '@grafana/ui';
import { FunctionEditor } from './FunctionEditor';
import { EditableParam, FunctionParamEditor } from './FunctionParamEditor';
import { mapFuncInstanceToParams } from './helpers';
import { MetricFunc } from '../../types';
export type FunctionEditorProps = {
func: MetricFunc;
onMoveLeft: (func: MetricFunc) => void;
onMoveRight: (func: MetricFunc) => void;
onRemove: (func: MetricFunc) => void;
onParamChange: (func: MetricFunc, index: number, value: string) => void;
};
/**
* Allows editing function params and removing/moving a function (note: editing function name is not supported)
*/
export function ZabbixFunctionEditor({ func, onMoveLeft, onMoveRight, onRemove, onParamChange }: FunctionEditorProps) {
const styles = useStyles2(getStyles);
// keep track of mouse over and isExpanded state to display buttons for adding optional/multiple params
// only when the user mouse over over the function editor OR any param editor is expanded.
const [mouseOver, setIsMouseOver] = useState(false);
const [expanded, setIsExpanded] = useState(false);
let params = mapFuncInstanceToParams(func);
params = params.filter((p: EditableParam, index: number) => {
// func.added is set for newly added functions - see autofocus below
return (index < func.def.params.length && !p.optional) || func.added || p.value || expanded || mouseOver;
});
return (
<div
className={cx(styles.container)}
onMouseOver={() => setIsMouseOver(true)}
onMouseLeave={() => setIsMouseOver(false)}
>
<HorizontalGroup spacing="none">
<FunctionEditor func={func} onMoveLeft={onMoveLeft} onMoveRight={onMoveRight} onRemove={onRemove} />
<InlineLabel className={styles.label}>(</InlineLabel>
{params.map((editableParam: EditableParam, index: number) => {
return (
<React.Fragment key={index}>
<FunctionParamEditor
autofocus={index === 0 && func.added}
editableParam={editableParam}
onChange={(value) => {
if (value !== '' || editableParam.optional) {
// dispatch(actions.updateFunctionParam({ func, index, value }));
onParamChange(func, index, value);
}
setIsExpanded(false);
setIsMouseOver(false);
}}
onExpandedChange={setIsExpanded}
/>
{index !== params.length - 1 ? ',' : ''}
</React.Fragment>
);
})}
<InlineLabel className={styles.label}>)</InlineLabel>
</HorizontalGroup>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
marginRight: theme.spacing(0.5),
padding: `0 ${theme.spacing(1)}`,
height: `${theme.v1.spacing.formInputHeight}px`,
}),
error: css`
border: 1px solid ${theme.colors.error.main};
`,
label: css({
padding: 0,
margin: 0,
}),
button: css({
padding: theme.spacing(0.5),
}),
});

View File

@@ -0,0 +1,58 @@
import { SelectableValue } from '@grafana/data';
import { MetricFunc } from '../../types';
export type ParamDef = {
name: string;
type: string;
options?: Array<string | number>;
multiple?: boolean;
optional?: boolean;
version?: string;
};
export type EditableParam = {
name: string;
value: string;
optional: boolean;
multiple: boolean;
options: Array<SelectableValue<string>>;
};
function createEditableParam(paramDef: ParamDef, additional: boolean, value?: string | number): EditableParam {
return {
name: paramDef.name,
value: value?.toString() || '',
optional: !!paramDef.optional || additional, // only first param is required when multiple are allowed
multiple: !!paramDef.multiple,
options:
paramDef.options?.map((option: string | number) => ({
value: option.toString(),
label: option.toString(),
})) ?? [],
};
}
/**
* Create a list of params that can be edited in the function editor.
*/
export function mapFuncInstanceToParams(func: MetricFunc): EditableParam[] {
// list of required parameters (from func.def)
const params: EditableParam[] = func.def.params.map((paramDef: ParamDef, index: number) =>
createEditableParam(paramDef, false, func.params[index])
);
// list of additional (multiple or optional) params entered by the user
while (params.length < func.params.length) {
const paramDef = func.def.params[func.def.params.length - 1];
const value = func.params[params.length];
params.push(createEditableParam(paramDef, true, value));
}
// extra "fake" param to allow adding more multiple values at the end
if (params.length && params[params.length - 1].value && params[params.length - 1]?.multiple) {
const paramDef = func.def.params[func.def.params.length - 1];
params.push(createEditableParam(paramDef, true, ''));
}
return params;
}

View File

@@ -1,67 +0,0 @@
import React from 'react';
const DOCS_FUNC_REF_URL = 'https://alexanderzobnin.github.io/grafana-zabbix/reference/functions/';
export interface FunctionDescriptor {
text: string;
params: string[];
def: {
category: string;
defaultParams: string[];
description?: string;
fake: boolean;
name: string;
params: string[];
};
}
export interface FunctionEditorControlsProps {
onMoveLeft: (func: FunctionDescriptor) => void;
onMoveRight: (func: FunctionDescriptor) => void;
onRemove: (func: FunctionDescriptor) => void;
}
const FunctionHelpButton = (props: { description: string; name: string; onDescriptionShow: () => void }) => {
if (props.description) {
return <span className="pointer fa fa-question-circle" onClick={props.onDescriptionShow} />;
}
return (
<span
className="pointer fa fa-question-circle"
onClick={() => {
window.open(
DOCS_FUNC_REF_URL + '#' + props.name,
'_blank'
);
}}
/>
);
};
export const FunctionEditorControls = (
props: FunctionEditorControlsProps & {
func: FunctionDescriptor;
onDescriptionShow: () => void;
}
) => {
const { func, onMoveLeft, onMoveRight, onRemove, onDescriptionShow } = props;
return (
<div
style={{
display: 'flex',
width: '60px',
justifyContent: 'space-between',
}}
>
<span className="pointer fa fa-arrow-left" onClick={() => onMoveLeft(func)} />
<FunctionHelpButton
name={func.def.name}
description={func.def.description}
onDescriptionShow={onDescriptionShow}
/>
<span className="pointer fa fa-remove" onClick={() => onRemove(func)} />
<span className="pointer fa fa-arrow-right" onClick={() => onMoveRight(func)} />
</div>
);
};

View File

@@ -0,0 +1,212 @@
import React, { useEffect } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
import * as c from '../constants';
import * as migrations from '../migrations';
import { ZabbixDatasource } from '../datasource';
import { MetricFunc, ShowProblemTypes, ZabbixDSOptions, ZabbixMetricsQuery, ZabbixQueryOptions } from '../types';
import { MetricsQueryEditor } from './QueryEditor/MetricsQueryEditor';
import { QueryFunctionsEditor } from './QueryEditor/QueryFunctionsEditor';
import { QueryOptionsEditor } from './QueryEditor/QueryOptionsEditor';
import { TextMetricsQueryEditor } from './QueryEditor/TextMetricsQueryEditor';
import { ProblemsQueryEditor } from './QueryEditor/ProblemsQueryEditor';
import { ItemIdQueryEditor } from './QueryEditor/ItemIdQueryEditor';
import { ITServicesQueryEditor } from './QueryEditor/ITServicesQueryEditor';
import { TriggersQueryEditor } from './QueryEditor/TriggersQueryEditor';
const zabbixQueryTypeOptions: Array<SelectableValue<string>> = [
{
value: c.MODE_METRICS,
label: 'Metrics',
description: 'Query numeric metrics',
},
{
value: c.MODE_TEXT,
label: 'Text',
description: 'Query text data',
},
{
value: c.MODE_ITSERVICE,
label: 'IT Services',
description: 'Query IT Services data',
},
{
value: c.MODE_ITEMID,
label: 'Item Id',
description: 'Query metrics by item ids',
},
{
value: c.MODE_TRIGGERS,
label: 'Triggers',
description: 'Query triggers data',
},
{
value: c.MODE_PROBLEMS,
label: 'Problems',
description: 'Query problems',
},
];
const getDefaultQuery: () => Partial<ZabbixMetricsQuery> = () => ({
queryType: c.MODE_METRICS,
group: { filter: '' },
host: { filter: '' },
application: { filter: '' },
itemTag: { filter: '' },
item: { filter: '' },
functions: [],
triggers: {
count: true,
minSeverity: 3,
acknowledged: 2,
},
trigger: { filter: '' },
tags: { filter: '' },
proxy: { filter: '' },
textFilter: '',
options: {
showDisabledItems: false,
skipEmptyValues: false,
disableDataAlignment: false,
useZabbixValueMapping: false,
},
table: {
skipEmptyValues: false,
},
});
function getSLAQueryDefaults() {
return {
itServiceFilter: '',
slaProperty: 'sla',
slaInterval: 'none',
};
}
function getProblemsQueryDefaults(): Partial<ZabbixMetricsQuery> {
return {
showProblems: ShowProblemTypes.Problems,
options: {
minSeverity: 0,
sortProblems: 'default',
acknowledged: 2,
hostsInMaintenance: false,
hostProxy: false,
limit: c.DEFAULT_ZABBIX_PROBLEMS_LIMIT,
useTimeRange: false,
},
};
}
export interface ZabbixQueryEditorProps
extends QueryEditorProps<ZabbixDatasource, ZabbixMetricsQuery, ZabbixDSOptions> {}
export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: ZabbixQueryEditorProps) => {
query = { ...getDefaultQuery(), ...query };
const { queryType } = query;
if (queryType === c.MODE_PROBLEMS || queryType === c.MODE_TRIGGERS) {
const defaults = getProblemsQueryDefaults();
query = { ...defaults, ...query };
query.options = { ...defaults.options, ...query.options };
}
if (queryType === c.MODE_ITSERVICE) {
query = { ...getSLAQueryDefaults(), ...query };
}
// Migrate query on load
useEffect(() => {
const migratedQuery = migrations.migrate(query);
onChange(migratedQuery);
}, []);
const onPropChange = (prop: string) => {
return (option: SelectableValue) => {
if (option.value !== null) {
onChangeInternal({ ...query, [prop]: option.value });
}
};
};
const onChangeInternal = (query: ZabbixMetricsQuery) => {
onChange(query);
onRunQuery();
};
const onOptionsChange = (options: ZabbixQueryOptions) => {
onChangeInternal({ ...query, options });
};
const getSelectableValue = (value: string): SelectableValue<string> => {
return { value, label: value };
};
const renderMetricsEditor = () => {
return (
<>
<MetricsQueryEditor query={query} datasource={datasource} onChange={onChangeInternal} />
<QueryFunctionsEditor query={query} onChange={onChangeInternal} />
</>
);
};
const renderItemIdsEditor = () => {
return (
<>
<ItemIdQueryEditor query={query} onChange={onChangeInternal} />
<QueryFunctionsEditor query={query} onChange={onChangeInternal} />
</>
);
};
const renderTextMetricsEditor = () => {
return (
<>
<TextMetricsQueryEditor query={query} datasource={datasource} onChange={onChangeInternal} />
{/* <QueryFunctionsEditor query={query} onChange={onChangeInternal} /> */}
</>
);
};
const renderITServicesEditor = () => {
return (
<>
<ITServicesQueryEditor query={query} datasource={datasource} onChange={onChangeInternal} />
<QueryFunctionsEditor query={query} onChange={onChangeInternal} />
</>
);
};
const renderProblemsEditor = () => {
return <ProblemsQueryEditor query={query} datasource={datasource} onChange={onChangeInternal} />;
};
const renderTriggersEditor = () => {
return <TriggersQueryEditor query={query} datasource={datasource} onChange={onChangeInternal} />;
};
return (
<>
<InlineFieldRow>
<InlineField label="Query type" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={queryType}
options={zabbixQueryTypeOptions}
onChange={onPropChange('queryType')}
/>
</InlineField>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</InlineFieldRow>
{queryType === c.MODE_METRICS && renderMetricsEditor()}
{queryType === c.MODE_ITEMID && renderItemIdsEditor()}
{queryType === c.MODE_TEXT && renderTextMetricsEditor()}
{queryType === c.MODE_ITSERVICE && renderITServicesEditor()}
{queryType === c.MODE_PROBLEMS && renderProblemsEditor()}
{queryType === c.MODE_TRIGGERS && renderTriggersEditor()}
<QueryOptionsEditor queryType={queryType} queryOptions={query.options} onChange={onOptionsChange} />
</>
);
};

View File

@@ -0,0 +1,102 @@
import _ from 'lodash';
import React, { useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { InlineField, Select } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils';
import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types';
const slaPropertyList: SelectableValue<string>[] = [
{ label: 'Status', value: 'status' },
{ label: 'SLA', value: 'sla' },
{ label: 'OK time', value: 'okTime' },
{ label: 'Problem time', value: 'problemTime' },
{ label: 'Down time', value: 'downtimeTime' },
];
const slaIntervals: SelectableValue<string>[] = [
{ label: 'No interval', value: 'none' },
{ label: 'Auto', value: 'auto' },
{ label: '1 hour', value: '1h' },
{ label: '12 hours', value: '12h' },
{ label: '24 hours', value: '1d' },
{ label: '1 week', value: '1w' },
{ label: '1 month', value: '1M' },
];
export interface Props {
query: ZabbixMetricsQuery;
datasource: ZabbixDatasource;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const ITServicesQueryEditor = ({ query, datasource, onChange }: Props) => {
const loadITServiceOptions = async () => {
const services = await datasource.zabbix.getITService();
const options = services?.map((s) => ({
value: s.name,
label: s.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: itServicesLoading, value: itServicesOptions }, fetchITServices] = useAsyncFn(async () => {
const options = await loadITServiceOptions();
return options;
}, []);
useEffect(() => {
fetchITServices();
}, []);
const onPropChange = (prop: string) => {
return (option: SelectableValue) => {
if (option.value) {
onChange({ ...query, [prop]: option.value });
}
};
};
const onITServiceChange = (value: string) => {
if (value !== null) {
onChange({ ...query, itServiceFilter: value });
}
};
return (
<QueryEditorRow>
<InlineField label="IT Service" labelWidth={12}>
<MetricPicker
width={24}
value={query.itServiceFilter}
options={itServicesOptions}
isLoading={itServicesLoading}
onChange={onITServiceChange}
/>
</InlineField>
<InlineField label="Property" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.slaProperty}
options={slaPropertyList}
onChange={onPropChange('slaProperty')}
/>
</InlineField>
<InlineField label="Interval" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.slaInterval}
options={slaIntervals}
onChange={onPropChange('slaInterval')}
/>
</InlineField>
</QueryEditorRow>
);
};

View File

@@ -0,0 +1,26 @@
import React, { FormEvent } from 'react';
import { InlineField, Input } from '@grafana/ui';
import { ZabbixMetricsQuery } from '../../types';
import { QueryEditorRow } from './QueryEditorRow';
export interface Props {
query: ZabbixMetricsQuery;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const ItemIdQueryEditor = ({ query, onChange }: Props) => {
const onItemIdsChange = (v: FormEvent<HTMLInputElement>) => {
const newValue = v?.currentTarget?.value;
if (newValue !== null) {
onChange({ ...query, itemids: newValue });
}
};
return (
<QueryEditorRow>
<InlineField label="Item Ids" labelWidth={12}>
<Input width={24} defaultValue={query.itemids} onBlur={onItemIdsChange} />
</InlineField>
</QueryEditorRow>
);
};

View File

@@ -0,0 +1,174 @@
import _ from 'lodash';
import React, { useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { InlineField } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils';
import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types';
export interface Props {
query: ZabbixMetricsQuery;
datasource: ZabbixDatasource;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const MetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
const loadGroupOptions = async () => {
const groups = await datasource.zabbix.getAllGroups();
const options = groups?.map((group) => ({
value: group.name,
label: group.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: groupsLoading, value: groupsOptions }, fetchGroups] = useAsyncFn(async () => {
const options = await loadGroupOptions();
return options;
}, []);
const loadHostOptions = async (group: string) => {
const groupFilter = datasource.replaceTemplateVars(group);
const hosts = await datasource.zabbix.getAllHosts(groupFilter);
let options: SelectableValue<string>[] = hosts?.map((host) => ({
value: host.name,
label: host.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(query.group.filter);
return options;
}, [query.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);
let options: SelectableValue<string>[] = apps?.map((app) => ({
value: app.name,
label: app.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => {
const options = await loadAppOptions(query.group.filter, query.host.filter);
return options;
}, [query.group.filter, query.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);
let itemOptions: SelectableValue<string>[] = items?.map((item) => ({
value: item.name,
label: item.name,
}));
itemOptions = _.uniqBy(itemOptions, (o) => o.value);
itemOptions.unshift(...getVariableOptions());
return itemOptions;
};
const [{ loading: itemsLoading, value: itemOptions }, fetchItems] = useAsyncFn(async () => {
const options = await loadItemOptions(
query.group.filter,
query.host.filter,
query.application.filter,
query.itemTag.filter
);
return options;
}, [query.group.filter, query.host.filter, query.application.filter, query.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);
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
fetchHosts();
}, [groupFilter]);
useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
useEffect(() => {
fetchItems();
}, [groupFilter, hostFilter, appFilter, tagFilter]);
const onFilterChange = (prop: string) => {
return (value: string) => {
if (value !== null) {
onChange({ ...query, [prop]: { filter: value } });
}
};
};
return (
<>
<QueryEditorRow>
<InlineField label="Group" labelWidth={12}>
<MetricPicker
width={24}
value={query.group.filter}
options={groupsOptions}
isLoading={groupsLoading}
onChange={onFilterChange('group')}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}>
<MetricPicker
width={24}
value={query.host.filter}
options={hostOptions}
isLoading={hostsLoading}
onChange={onFilterChange('host')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Application" labelWidth={12}>
<MetricPicker
width={24}
value={query.application.filter}
options={appOptions}
isLoading={appsLoading}
onChange={onFilterChange('application')}
/>
</InlineField>
<InlineField label="Item" labelWidth={12}>
<MetricPicker
width={24}
value={query.item.filter}
options={itemOptions}
isLoading={itemsLoading}
onChange={onFilterChange('item')}
/>
</InlineField>
</QueryEditorRow>
</>
);
};

View File

@@ -0,0 +1,232 @@
import _ from 'lodash';
import React, { useEffect, FormEvent } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { InlineField, Input, Select } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils';
import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types';
const showProblemsOptions: SelectableValue<string>[] = [
{ label: 'Problems', value: 'problems' },
{ label: 'Recent problems', value: 'recent' },
{ label: 'History', value: 'history' },
];
const severityOptions: SelectableValue<number>[] = [
{ value: 0, label: 'Not classified' },
{ value: 1, label: 'Information' },
{ value: 2, label: 'Warning' },
{ value: 3, label: 'Average' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Disaster' },
];
export interface Props {
query: ZabbixMetricsQuery;
datasource: ZabbixDatasource;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const ProblemsQueryEditor = ({ query, datasource, onChange }: Props) => {
const loadGroupOptions = async () => {
const groups = await datasource.zabbix.getAllGroups();
const options = groups?.map((group) => ({
value: group.name,
label: group.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: groupsLoading, value: groupsOptions }, fetchGroups] = useAsyncFn(async () => {
const options = await loadGroupOptions();
return options;
}, []);
const loadHostOptions = async (group: string) => {
const groupFilter = datasource.replaceTemplateVars(group);
const hosts = await datasource.zabbix.getAllHosts(groupFilter);
let options: SelectableValue<string>[] = hosts?.map((host) => ({
value: host.name,
label: host.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(query.group.filter);
return options;
}, [query.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);
let options: SelectableValue<string>[] = apps?.map((app) => ({
value: app.name,
label: app.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => {
const options = await loadAppOptions(query.group.filter, query.host.filter);
return options;
}, [query.group.filter, query.host.filter]);
const loadProxyOptions = async () => {
const proxies = await datasource.zabbix.getProxies();
const options = proxies?.map((proxy) => ({
value: proxy.host,
label: proxy.host,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: proxiesLoading, value: proxiesOptions }, fetchProxies] = useAsyncFn(async () => {
const options = await loadProxyOptions();
return options;
}, []);
// Update suggestions on every metric change
const groupFilter = datasource.replaceTemplateVars(query.group?.filter);
const hostFilter = datasource.replaceTemplateVars(query.host?.filter);
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
fetchHosts();
}, [groupFilter]);
useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
useEffect(() => {
fetchProxies();
}, []);
const onTextFilterChange = (prop: string) => {
return (v: FormEvent<HTMLInputElement>) => {
const newValue = v?.currentTarget?.value;
if (newValue !== null) {
onChange({ ...query, [prop]: { filter: newValue } });
}
};
};
const onFilterChange = (prop: string) => {
return (value: string) => {
if (value !== null) {
onChange({ ...query, [prop]: { filter: value } });
}
};
};
const onPropChange = (prop: string) => {
return (option: SelectableValue) => {
if (option.value !== null) {
onChange({ ...query, [prop]: option.value });
}
};
};
const onMinSeverityChange = (option: SelectableValue) => {
if (option.value !== null) {
onChange({ ...query, options: { ...query.options, minSeverity: option.value } });
}
};
return (
<>
<QueryEditorRow>
<InlineField label="Group" labelWidth={12}>
<MetricPicker
width={24}
value={query.group?.filter}
options={groupsOptions}
isLoading={groupsLoading}
onChange={onFilterChange('group')}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}>
<MetricPicker
width={24}
value={query.host?.filter}
options={hostOptions}
isLoading={hostsLoading}
onChange={onFilterChange('host')}
/>
</InlineField>
<InlineField label="Proxy" labelWidth={12}>
<MetricPicker
width={24}
value={query.proxy?.filter}
options={proxiesOptions}
isLoading={proxiesLoading}
onChange={onFilterChange('proxy')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Application" labelWidth={12}>
<MetricPicker
width={24}
value={query.application?.filter}
options={appOptions}
isLoading={appsLoading}
onChange={onFilterChange('application')}
/>
</InlineField>
<InlineField label="Problem" labelWidth={12}>
<Input
width={24}
defaultValue={query.trigger?.filter}
placeholder="Problem name"
onBlur={onTextFilterChange('trigger')}
/>
</InlineField>
<InlineField label="Tags" labelWidth={12}>
<Input
width={24}
defaultValue={query.tags?.filter}
placeholder="tag1:value1, tag2:value2"
onBlur={onTextFilterChange('tags')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Show" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.showProblems}
options={showProblemsOptions}
onChange={onPropChange('showProblems')}
/>
</InlineField>
<InlineField label="Min severity" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.options?.minSeverity}
options={severityOptions}
onChange={onMinSeverityChange}
/>
</InlineField>
</QueryEditorRow>
</>
);
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { InlineFieldRow } from '@grafana/ui';
export const QueryEditorRow = ({ children }: React.PropsWithChildren<{}>) => {
return (
<InlineFieldRow>
{children}
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</InlineFieldRow>
);
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { swap } from '../../utils';
import { createFuncInstance } from '../../metricFunctions';
import { FuncDef, MetricFunc, ZabbixMetricsQuery } from '../../types';
import { QueryEditorRow } from './QueryEditorRow';
import { InlineFormLabel } from '@grafana/ui';
import { ZabbixFunctionEditor } from '../FunctionEditor/ZabbixFunctionEditor';
import { AddZabbixFunction } from '../FunctionEditor/AddZabbixFunction';
export interface Props {
query: ZabbixMetricsQuery;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const QueryFunctionsEditor = ({ query, onChange }: Props) => {
const onFuncParamChange = (func: MetricFunc, index: number, value: string) => {
func.params[index] = value;
const funcIndex = query.functions.findIndex((f) => f === func);
const functions = query.functions;
functions[funcIndex] = func;
onChange({ ...query, functions });
};
const onMoveFuncLeft = (func: MetricFunc) => {
const index = query.functions.indexOf(func);
const functions = swap(query.functions, index, index - 1);
onChange({ ...query, functions });
};
const onMoveFuncRight = (func: MetricFunc) => {
const index = query.functions.indexOf(func);
const functions = swap(query.functions, index, index + 1);
onChange({ ...query, functions });
};
const onRemoveFunc = (func: MetricFunc) => {
const functions = query.functions?.filter((f) => f != func);
onChange({ ...query, functions });
};
const onFuncAdd = (def: FuncDef) => {
const newFunc = createFuncInstance(def);
newFunc.added = true;
let functions = query.functions.concat(newFunc);
functions = moveAliasFuncLast(functions);
// if ((newFunc.params.length && newFunc.added) || newFunc.def.params.length === 0) {
// }
onChange({ ...query, functions });
};
return (
<QueryEditorRow>
<InlineFormLabel width={6}>Functions</InlineFormLabel>
{query.functions?.map((f, i) => {
return (
<ZabbixFunctionEditor
func={f}
key={i}
onParamChange={onFuncParamChange}
onMoveLeft={onMoveFuncLeft}
onMoveRight={onMoveFuncRight}
onRemove={onRemoveFunc}
/>
);
})}
<AddZabbixFunction onFuncAdd={onFuncAdd} />
</QueryEditorRow>
);
};
function moveAliasFuncLast(functions: MetricFunc[]) {
const aliasFuncIndex = functions.findIndex((func) => func.def.category === 'Alias');
console.log(aliasFuncIndex);
if (aliasFuncIndex >= 0) {
const aliasFunc = functions[aliasFuncIndex];
functions.splice(aliasFuncIndex, 1);
functions.push(aliasFunc);
}
return functions;
}

View File

@@ -0,0 +1,232 @@
import { css } from '@emotion/css';
import React, { useState, FormEvent } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
HorizontalGroup,
Icon,
InlineField,
InlineFieldRow,
InlineSwitch,
Input,
Select,
useStyles2,
} from '@grafana/ui';
import * as c from '../../constants';
import { ZabbixQueryOptions } from '../../types';
const ackOptions: SelectableValue<number>[] = [
{ label: 'all triggers', value: 2 },
{ label: 'unacknowledged', value: 0 },
{ label: 'acknowledged', value: 1 },
];
const sortOptions: SelectableValue<string>[] = [
{ label: 'Default', value: 'default' },
{ label: 'Last change', value: 'lastchange' },
{ label: 'Severity', value: 'severity' },
];
interface Props {
queryType: string;
queryOptions: ZabbixQueryOptions;
onChange: (options: ZabbixQueryOptions) => void;
}
export const QueryOptionsEditor = ({ queryType, queryOptions, onChange }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const styles = useStyles2(getStyles);
const onLimitChange = (v: FormEvent<HTMLInputElement>) => {
const newValue = Number(v?.currentTarget?.value);
if (newValue !== null) {
onChange({ ...queryOptions, limit: newValue });
}
};
const onPropChange = (prop: string) => {
return (option: SelectableValue) => {
if (option.value !== null) {
onChange({ ...queryOptions, [prop]: option.value });
}
};
};
const renderClosed = () => {
return (
<>
<HorizontalGroup>
{!isOpen && <Icon name="angle-right" />}
{isOpen && <Icon name="angle-down" />}
<span className={styles.label}>Options</span>
<div className={styles.options}>{renderOptions()}</div>
</HorizontalGroup>
</>
);
};
const renderOptions = () => {
const elements: JSX.Element[] = [];
for (const key in queryOptions) {
if (queryOptions.hasOwnProperty(key)) {
const value = queryOptions[key];
if (value === true && value !== '' && value !== null && value !== undefined) {
elements.push(<span className={styles.optionContainer} key={key}>{`${key} = ${value}`}</span>);
}
}
}
return elements;
};
const renderEditor = () => {
return (
<div className={styles.editorContainer}>
{queryType === c.MODE_METRICS && renderMetricOptions()}
{queryType === c.MODE_ITEMID && renderMetricOptions()}
{queryType === c.MODE_ITSERVICE && renderMetricOptions()}
{queryType === c.MODE_TEXT && renderTextMetricsOptions()}
{queryType === c.MODE_PROBLEMS && renderProblemsOptions()}
{queryType === c.MODE_TRIGGERS && renderTriggersOptions()}
</div>
);
};
const renderMetricOptions = () => {
return (
<>
<InlineField label="Show disabled items" labelWidth={24}>
<InlineSwitch
value={queryOptions.showDisabledItems}
onChange={() => onChange({ ...queryOptions, showDisabledItems: !queryOptions.showDisabledItems })}
/>
</InlineField>
<InlineField label="Use Zabbix value mapping" labelWidth={24}>
<InlineSwitch
value={queryOptions.useZabbixValueMapping}
onChange={() => onChange({ ...queryOptions, useZabbixValueMapping: !queryOptions.useZabbixValueMapping })}
/>
</InlineField>
<InlineField label="Disable data alignment" labelWidth={24}>
<InlineSwitch
value={queryOptions.disableDataAlignment}
onChange={() => onChange({ ...queryOptions, disableDataAlignment: !queryOptions.disableDataAlignment })}
/>
</InlineField>
</>
);
};
const renderTextMetricsOptions = () => {
return (
<>
<InlineField label="Show disabled items" labelWidth={24}>
<InlineSwitch
value={queryOptions.showDisabledItems}
onChange={() => onChange({ ...queryOptions, showDisabledItems: !queryOptions.showDisabledItems })}
/>
</InlineField>
</>
);
};
const renderProblemsOptions = () => {
return (
<>
<InlineField label="Acknowledged" labelWidth={24}>
<Select
isSearchable={false}
width={24}
value={queryOptions.acknowledged}
options={ackOptions}
onChange={onPropChange('acknowledged')}
/>
</InlineField>
<InlineField label="Sort by" labelWidth={24}>
<Select
isSearchable={false}
width={24}
value={queryOptions.sortProblems}
options={sortOptions}
onChange={onPropChange('sortProblems')}
/>
</InlineField>
<InlineField label="Use time range" labelWidth={24}>
<InlineSwitch
value={queryOptions.useTimeRange}
onChange={() => onChange({ ...queryOptions, useTimeRange: !queryOptions.useTimeRange })}
/>
</InlineField>
<InlineField label="Hosts in maintenance" labelWidth={24}>
<InlineSwitch
value={queryOptions.hostsInMaintenance}
onChange={() => onChange({ ...queryOptions, hostsInMaintenance: !queryOptions.hostsInMaintenance })}
/>
</InlineField>
<InlineField label="Host proxy" labelWidth={24}>
<InlineSwitch
value={queryOptions.hostProxy}
onChange={() => onChange({ ...queryOptions, hostProxy: !queryOptions.hostProxy })}
/>
</InlineField>
<InlineField label="Limit" labelWidth={24}>
<Input width={12} type="number" defaultValue={queryOptions.limit} onBlur={onLimitChange} />
</InlineField>
</>
);
};
const renderTriggersOptions = () => {
return (
<>
<InlineField label="Acknowledged" labelWidth={24}>
<Select
isSearchable={false}
width={24}
value={queryOptions.acknowledged}
options={ackOptions}
onChange={onPropChange('acknowledged')}
/>
</InlineField>
</>
);
};
return (
<>
<InlineFieldRow>
<div className={styles.container} onClick={() => setIsOpen(!isOpen)}>
{renderClosed()}
</div>
</InlineFieldRow>
<InlineFieldRow>{isOpen && renderEditor()}</InlineFieldRow>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
marginRight: theme.spacing(0.5),
marginBottom: theme.spacing(0.5),
padding: `0 ${theme.spacing(1)}`,
height: `${theme.v1.spacing.formInputHeight}px`,
width: `100%`,
}),
label: css({
color: theme.colors.info.text,
fontWeight: theme.typography.fontWeightMedium,
cursor: 'pointer',
}),
options: css({
color: theme.colors.text.disabled,
fontSize: theme.typography.bodySmall.fontSize,
}),
optionContainer: css`
margin-right: ${theme.spacing(2)};
`,
editorContainer: css`
display: flex;
flex-direction: column;
margin-left: ${theme.spacing(4)};
`,
});

View File

@@ -0,0 +1,192 @@
import _ from 'lodash';
import React, { useEffect, FormEvent } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { InlineField, InlineSwitch, Input } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils';
import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types';
export interface Props {
query: ZabbixMetricsQuery;
datasource: ZabbixDatasource;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const TextMetricsQueryEditor = ({ query, datasource, onChange }: Props) => {
const loadGroupOptions = async () => {
const groups = await datasource.zabbix.getAllGroups();
const options = groups?.map((group) => ({
value: group.name,
label: group.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: groupsLoading, value: groupsOptions }, fetchGroups] = useAsyncFn(async () => {
const options = await loadGroupOptions();
return options;
}, []);
const loadHostOptions = async (group: string) => {
const groupFilter = datasource.replaceTemplateVars(group);
const hosts = await datasource.zabbix.getAllHosts(groupFilter);
let options: SelectableValue<string>[] = hosts?.map((host) => ({
value: host.name,
label: host.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(query.group.filter);
return options;
}, [query.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);
let options: SelectableValue<string>[] = apps?.map((app) => ({
value: app.name,
label: app.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => {
const options = await loadAppOptions(query.group.filter, query.host.filter);
return options;
}, [query.group.filter, query.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);
let itemOptions: SelectableValue<string>[] = items?.map((item) => ({
value: item.name,
label: item.name,
}));
itemOptions = _.uniqBy(itemOptions, (o) => o.value);
itemOptions.unshift(...getVariableOptions());
return itemOptions;
};
const [{ loading: itemsLoading, value: itemOptions }, fetchItems] = useAsyncFn(async () => {
const options = await loadItemOptions(
query.group.filter,
query.host.filter,
query.application.filter,
query.itemTag.filter
);
return options;
}, [query.group.filter, query.host.filter, query.application.filter, query.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);
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
fetchHosts();
}, [groupFilter]);
useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
useEffect(() => {
fetchItems();
}, [groupFilter, hostFilter, appFilter, tagFilter]);
const onTextFilterChange = (v: FormEvent<HTMLInputElement>) => {
const newValue = v?.currentTarget?.value;
if (newValue !== null) {
onChange({ ...query, textFilter: newValue });
}
};
const onFilterChange = (prop: string) => {
return (value: string) => {
if (value !== null) {
onChange({ ...query, [prop]: { filter: value } });
}
};
};
return (
<>
<QueryEditorRow>
<InlineField label="Group" labelWidth={12}>
<MetricPicker
width={24}
value={query.group.filter}
options={groupsOptions}
isLoading={groupsLoading}
onChange={onFilterChange('group')}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}>
<MetricPicker
width={24}
value={query.host.filter}
options={hostOptions}
isLoading={hostsLoading}
onChange={onFilterChange('host')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Application" labelWidth={12}>
<MetricPicker
width={24}
value={query.application.filter}
options={appOptions}
isLoading={appsLoading}
onChange={onFilterChange('application')}
/>
</InlineField>
<InlineField label="Item" labelWidth={12}>
<MetricPicker
width={24}
value={query.item.filter}
options={itemOptions}
isLoading={itemsLoading}
onChange={onFilterChange('item')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Text filter" labelWidth={12}>
<Input width={24} defaultValue={query.textFilter} onBlur={onTextFilterChange} />
</InlineField>
<InlineField label="Use capture groups" labelWidth={16}>
<InlineSwitch
value={query.useCaptureGroups}
onChange={() => onChange({ ...query, useCaptureGroups: !query.useCaptureGroups })}
/>
</InlineField>
</QueryEditorRow>
</>
);
};

View File

@@ -0,0 +1,160 @@
import _ from 'lodash';
import React, { useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { InlineField, InlineSwitch, Select } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
import { MetricPicker } from '../../../components';
import { getVariableOptions } from './utils';
import { ZabbixDatasource } from '../../datasource';
import { ZabbixMetricsQuery } from '../../types';
const severityOptions: SelectableValue<number>[] = [
{ value: 0, label: 'Not classified' },
{ value: 1, label: 'Information' },
{ value: 2, label: 'Warning' },
{ value: 3, label: 'Average' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Disaster' },
];
export interface Props {
query: ZabbixMetricsQuery;
datasource: ZabbixDatasource;
onChange: (query: ZabbixMetricsQuery) => void;
}
export const TriggersQueryEditor = ({ query, datasource, onChange }: Props) => {
const loadGroupOptions = async () => {
const groups = await datasource.zabbix.getAllGroups();
const options = groups?.map((group) => ({
value: group.name,
label: group.name,
}));
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: groupsLoading, value: groupsOptions }, fetchGroups] = useAsyncFn(async () => {
const options = await loadGroupOptions();
return options;
}, []);
const loadHostOptions = async (group: string) => {
const groupFilter = datasource.replaceTemplateVars(group);
const hosts = await datasource.zabbix.getAllHosts(groupFilter);
let options: SelectableValue<string>[] = hosts?.map((host) => ({
value: host.name,
label: host.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift({ value: '/.*/' });
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: hostsLoading, value: hostOptions }, fetchHosts] = useAsyncFn(async () => {
const options = await loadHostOptions(query.group.filter);
return options;
}, [query.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);
let options: SelectableValue<string>[] = apps?.map((app) => ({
value: app.name,
label: app.name,
}));
options = _.uniqBy(options, (o) => o.value);
options.unshift(...getVariableOptions());
return options;
};
const [{ loading: appsLoading, value: appOptions }, fetchApps] = useAsyncFn(async () => {
const options = await loadAppOptions(query.group.filter, query.host.filter);
return options;
}, [query.group.filter, query.host.filter]);
// Update suggestions on every metric change
const groupFilter = datasource.replaceTemplateVars(query.group?.filter);
const hostFilter = datasource.replaceTemplateVars(query.host?.filter);
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
fetchHosts();
}, [groupFilter]);
useEffect(() => {
fetchApps();
}, [groupFilter, hostFilter]);
const onFilterChange = (prop: string) => {
return (value: string) => {
if (value !== null) {
onChange({ ...query, [prop]: { filter: value } });
}
};
};
const onMinSeverityChange = (option: SelectableValue) => {
if (option.value !== null) {
onChange({ ...query, options: { ...query.options, minSeverity: option.value } });
}
};
return (
<>
<QueryEditorRow>
<InlineField label="Group" labelWidth={12}>
<MetricPicker
width={24}
value={query.group?.filter}
options={groupsOptions}
isLoading={groupsLoading}
onChange={onFilterChange('group')}
/>
</InlineField>
<InlineField label="Host" labelWidth={12}>
<MetricPicker
width={24}
value={query.host?.filter}
options={hostOptions}
isLoading={hostsLoading}
onChange={onFilterChange('host')}
/>
</InlineField>
</QueryEditorRow>
<QueryEditorRow>
<InlineField label="Application" labelWidth={12}>
<MetricPicker
width={24}
value={query.application?.filter}
options={appOptions}
isLoading={appsLoading}
onChange={onFilterChange('application')}
/>
</InlineField>
<InlineField label="Min severity" labelWidth={12}>
<Select
isSearchable={false}
width={24}
value={query.triggers?.minSeverity}
options={severityOptions}
onChange={onMinSeverityChange}
/>
</InlineField>
<InlineField label="Count" labelWidth={12}>
<InlineSwitch
value={query.triggers?.count}
onChange={() => onChange({ ...query, triggers: { ...query.triggers, count: !query.triggers?.count } })}
/>
</InlineField>
</QueryEditorRow>
</>
);
};

View File

@@ -0,0 +1,13 @@
import { getTemplateSrv } from '@grafana/runtime';
export const getVariableOptions = () => {
const variables = getTemplateSrv()
.getVariables()
.filter((v) => {
return v.type !== 'datasource' && v.type !== 'interval';
});
return variables?.map((v) => ({
value: `$${v.name}`,
label: `$${v.name}`,
}));
};

View File

@@ -35,10 +35,10 @@ export class ZabbixVariableQueryEditor extends PureComponent<VariableQueryProps,
this.state = {
selectedQueryType,
legacyQuery: this.props.query,
...query
...query,
};
} else if (this.props.query) {
const query = (this.props.query as VariableQuery);
const query = this.props.query as VariableQuery;
const selectedQueryType = this.getSelectedQueryType(query.queryType);
this.state = {
...this.defaults,
@@ -51,7 +51,7 @@ export class ZabbixVariableQueryEditor extends PureComponent<VariableQueryProps,
}
getSelectedQueryType(queryType: VariableQueryTypes) {
return this.queryTypes.find(q => q.value === queryType);
return this.queryTypes.find((q) => q.value === queryType);
}
handleQueryUpdate = (evt: React.ChangeEvent<HTMLInputElement>, prop: string) => {
@@ -108,32 +108,32 @@ export class ZabbixVariableQueryEditor extends PureComponent<VariableQueryProps,
<InlineFormLabel width={10}>Group</InlineFormLabel>
<ZabbixInput
value={group}
onChange={evt => this.handleQueryUpdate(evt, 'group')}
onChange={(evt) => this.handleQueryUpdate(evt, 'group')}
onBlur={this.handleQueryChange}
/>
</div>
{selectedQueryType.value !== VariableQueryTypes.Group &&
{selectedQueryType.value !== VariableQueryTypes.Group && (
<div className="gf-form max-width-30">
<InlineFormLabel width={10}>Host</InlineFormLabel>
<ZabbixInput
value={host}
onChange={evt => this.handleQueryUpdate(evt, 'host')}
onChange={(evt) => this.handleQueryUpdate(evt, 'host')}
onBlur={this.handleQueryChange}
/>
</div>
}
)}
</div>
{(selectedQueryType.value === VariableQueryTypes.Application ||
selectedQueryType.value === VariableQueryTypes.ItemTag ||
selectedQueryType.value === VariableQueryTypes.Item ||
selectedQueryType.value === VariableQueryTypes.ItemValues) &&
selectedQueryType.value === VariableQueryTypes.ItemValues) && (
<div className="gf-form-inline">
{supportsItemTags && (
<div className="gf-form max-width-30">
<InlineFormLabel width={10}>Item tag</InlineFormLabel>
<ZabbixInput
value={itemTag}
onChange={evt => this.handleQueryUpdate(evt, 'itemTag')}
onChange={(evt) => this.handleQueryUpdate(evt, 'itemTag')}
onBlur={this.handleQueryChange}
/>
</div>
@@ -143,35 +143,33 @@ export class ZabbixVariableQueryEditor extends PureComponent<VariableQueryProps,
<InlineFormLabel width={10}>Application</InlineFormLabel>
<ZabbixInput
value={application}
onChange={evt => this.handleQueryUpdate(evt, 'application')}
onChange={(evt) => this.handleQueryUpdate(evt, 'application')}
onBlur={this.handleQueryChange}
/>
</div>
)}
{(selectedQueryType.value === VariableQueryTypes.Item ||
selectedQueryType.value === VariableQueryTypes.ItemValues) &&
selectedQueryType.value === VariableQueryTypes.ItemValues) && (
<div className="gf-form max-width-30">
<InlineFormLabel width={10}>Item</InlineFormLabel>
<ZabbixInput
value={item}
onChange={evt => this.handleQueryUpdate(evt, 'item')}
onChange={(evt) => this.handleQueryUpdate(evt, 'item')}
onBlur={this.handleQueryChange}
/>
</div>
}
)}
</div>
}
)}
{legacyQuery &&
{legacyQuery && (
<div className="gf-form">
<InlineFormLabel width={10} tooltip="Original query string, read-only">Legacy Query</InlineFormLabel>
<Input
css=""
value={legacyQuery}
readOnly={true}
/>
<InlineFormLabel width={10} tooltip="Original query string, read-only">
Legacy Query
</InlineFormLabel>
<Input value={legacyQuery} readOnly={true} />
</div>
}
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
import React, { FC } from 'react';
import { css, cx } from '@emotion/css';
import { EventsWithValidation, ValidationEvents, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from '@emotion/css';
import { EventsWithValidation, ValidationEvents, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { isRegex, variableRegex } from '../utils';
import * as grafanaUi from '@grafana/ui';
@@ -9,19 +9,19 @@ const Input = (grafanaUi as any).LegacyForms?.Input || (grafanaUi as any).Input;
const variablePattern = RegExp(`^${variableRegex.source}`);
const getStyles = (theme: GrafanaTheme) => ({
const getStyles = (theme: GrafanaTheme2) => ({
inputRegex: css`
color: ${theme.palette.orange}
color: ${theme.colors.warning.main};
`,
inputVariable: css`
color: ${theme.colors.textBlue}
color: ${theme.colors.action.focus};
`,
});
const zabbixInputValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: value => {
rule: (value) => {
if (!value) {
return true;
}
@@ -35,7 +35,7 @@ const zabbixInputValidationEvents: ValidationEvents = {
errorMessage: 'Not a valid regex',
},
{
rule: value => {
rule: (value) => {
if (value === '*') {
return false;
}
@@ -47,8 +47,7 @@ const zabbixInputValidationEvents: ValidationEvents = {
};
export const ZabbixInput: FC<any> = ({ value, ref, validationEvents, ...restProps }) => {
const theme = useTheme();
const styles = getStyles(theme);
const styles = useStyles2(getStyles);
let inputClass = styles.inputRegex;
if (variablePattern.test(value as string)) {
@@ -57,12 +56,5 @@ export const ZabbixInput: FC<any> = ({ value, ref, validationEvents, ...restProp
inputClass = styles.inputRegex;
}
return (
<Input
className={inputClass}
value={value}
validationEvents={zabbixInputValidationEvents}
{...restProps}
/>
);
return <Input className={inputClass} value={value} validationEvents={zabbixInputValidationEvents} {...restProps} />;
};

View File

@@ -22,8 +22,10 @@ import {
DataSourceInstanceSettings,
FieldType,
isDataFrame,
LoadingState
LoadingState,
toDataFrame,
} from '@grafana/data';
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDSOptions> {
name: string;
@@ -53,6 +55,11 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
this.templateSrv = templateSrv;
this.enableDebugLog = config.buildInfo.env === 'development';
this.annotations = {
QueryEditor: AnnotationQueryEditor,
prepareAnnotation: migrations.prepareAnnotation,
};
// Use custom format for template variables
this.replaceTemplateVars = _.partial(replaceTemplateVars, this.templateSrv);
@@ -108,7 +115,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
*/
query(request: DataQueryRequest<ZabbixMetricsQuery>) {
// Migrate old targets
const requestTargets = request.targets.map(t => {
const requestTargets = request.targets.map((t) => {
// Prevent changes of original object
const target = _.cloneDeep(t);
return migrations.migrate(target);
@@ -117,11 +124,16 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
const backendResponsePromise = this.backendQuery({ ...request, targets: requestTargets });
const dbConnectionResponsePromise = this.dbConnectionQuery({ ...request, targets: requestTargets });
const frontendResponsePromise = this.frontendQuery({ ...request, targets: requestTargets });
const annotationResposePromise = this.annotationRequest({ ...request, targets: requestTargets });
return Promise.all([backendResponsePromise, dbConnectionResponsePromise, frontendResponsePromise])
.then(rsp => {
return Promise.all([
backendResponsePromise,
dbConnectionResponsePromise,
frontendResponsePromise,
annotationResposePromise,
]).then((rsp) => {
// Merge backend and frontend queries results
const [backendRes, dbConnectionRes, frontendRes] = rsp;
const [backendRes, dbConnectionRes, frontendRes, annotationRes] = rsp;
if (dbConnectionRes.data) {
backendRes.data = backendRes.data.concat(dbConnectionRes.data);
}
@@ -129,6 +141,10 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
backendRes.data = backendRes.data.concat(frontendRes.data);
}
if (annotationRes.data) {
backendRes.data = backendRes.data.concat(annotationRes.data);
}
return {
data: backendRes.data,
state: LoadingState.Done,
@@ -144,7 +160,8 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
// Add range variables
request.scopedVars = Object.assign({}, request.scopedVars, utils.getRangeScopedVars(request.range));
const queries = _.compact(targets.map((query) => {
const queries = _.compact(
targets.map((query) => {
// Don't request for hidden targets
if (query.hide) {
return null;
@@ -158,7 +175,8 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
intervalMs,
maxDataPoints,
};
}));
})
);
// Return early if no queries exist
if (!queries.length) {
@@ -175,12 +193,14 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
let rsp: any;
try {
rsp = await getBackendSrv().fetch({
rsp = await getBackendSrv()
.fetch({
url: '/api/ds/query',
method: 'POST',
data: body,
requestId,
}).toPromise();
})
.toPromise();
} catch (err) {
return toDataQueryResponse(err);
}
@@ -198,8 +218,8 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
}
async frontendQuery(request: DataQueryRequest<any>): Promise<DataQueryResponse> {
const frontendTargets = request.targets.filter(t => !(this.isBackendTarget(t) || this.isDBConnectionTarget(t)));
const promises = _.map(frontendTargets, target => {
const frontendTargets = request.targets.filter((t) => !(this.isBackendTarget(t) || this.isDBConnectionTarget(t)));
const promises = _.map(frontendTargets, (target) => {
// Don't request for hidden targets
if (target.hide) {
return [];
@@ -234,7 +254,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
// Data for panel (all targets)
return Promise.all(_.flatten(promises))
.then(_.flatten)
.then(data => {
.then((data) => {
if (data && data.length > 0 && isDataFrame(data[0]) && !utils.isProblemsDataFrame(data[0])) {
data = responseHandler.alignFrames(data);
if (responseHandler.isConvertibleToWide(data)) {
@@ -249,7 +269,8 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
async dbConnectionQuery(request: DataQueryRequest<any>): Promise<DataQueryResponse> {
const targets = request.targets.filter(this.isDBConnectionTarget);
const queries = _.compact(targets.map((target) => {
const queries = _.compact(
targets.map((target) => {
// Don't request for hidden targets
if (target.hide) {
return [];
@@ -272,11 +293,12 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
} else {
return [];
}
}));
})
);
const promises: Promise<DataQueryResponse> = Promise.all(queries)
.then(_.flatten)
.then(data => ({ data }));
.then((data) => ({ data }));
return promises;
}
@@ -300,7 +322,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
*/
async queryNumericData(target, timeRange, useTrends, request): Promise<any> {
const getItemOptions = {
itemtype: 'num'
itemtype: 'num',
};
const items = await this.zabbix.getItemsFromTarget(target, getItemOptions);
@@ -343,7 +365,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
url: `/api/datasources/${this.datasourceId}/resources/db-connection-post`,
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
hideFromInspector: false,
data: {
@@ -379,10 +401,10 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
getTrendValueType(target) {
// Find trendValue() function and get specified trend value
const trendFunctions = _.map(metricFunctions.getCategories()['Trends'], 'name');
const trendValueFunc = _.find(target.functions, func => {
const trendValueFunc = _.find(target.functions, (func) => {
return _.includes(trendFunctions, func.def.name);
});
return trendValueFunc ? trendValueFunc.params[0] : "avg";
return trendValueFunc ? trendValueFunc.params[0] : 'avg';
}
sortByRefId(response: DataQueryResponse) {
@@ -413,15 +435,16 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
*/
queryTextData(target, timeRange) {
const options = {
itemtype: 'text'
itemtype: 'text',
};
return this.zabbix.getItemsFromTarget(target, options)
.then(items => {
return this.zabbix
.getItemsFromTarget(target, options)
.then((items) => {
return this.zabbix.getHistoryText(items, timeRange, target);
})
.then(result => {
.then((result) => {
if (target.resultFormat !== 'table') {
return result.map(s => responseHandler.seriesToDataFrame(s, target, [], FieldType.string));
return result.map((s) => responseHandler.seriesToDataFrame(s, target, [], FieldType.string));
}
return result;
});
@@ -433,14 +456,13 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
queryItemIdData(target, timeRange, useTrends, options) {
let itemids = target.itemids;
itemids = this.templateSrv.replace(itemids, options.scopedVars, zabbixItemIdsTemplateFormat);
itemids = _.map(itemids.split(','), itemid => itemid.trim());
itemids = _.map(itemids.split(','), (itemid) => itemid.trim());
if (!itemids) {
return [];
}
return this.zabbix.getItemsByIDs(itemids)
.then(items => {
return this.zabbix.getItemsByIDs(itemids).then((items) => {
return this.queryNumericDataForItems(items, target, timeRange, useTrends, options);
});
}
@@ -468,7 +490,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
let itservices = await this.zabbix.getITServices(itServiceFilter);
if (request.isOldVersion) {
itservices = _.filter(itservices, { 'serviceid': target.itservice?.serviceid });
itservices = _.filter(itservices, { serviceid: target.itservice?.serviceid });
}
const itservicesdp = await this.zabbix.getSLA(itservices, timeRange, target, request);
const backendRequest = responseHandler.itServiceResponseToTimeSeries(itservicesdp, target.slaInterval);
@@ -478,8 +500,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
queryTriggersData(target, timeRange) {
const [timeFrom, timeTo] = timeRange;
return this.zabbix.getHostsFromTarget(target)
.then(results => {
return this.zabbix.getHostsFromTarget(target).then((results) => {
const [hosts, apps] = results;
if (hosts.length) {
const hostids = _.map(hosts, 'hostid');
@@ -489,14 +510,13 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
acknowledged: target.triggers.acknowledged,
count: target.triggers.count,
timeFrom: timeFrom,
timeTo: timeTo
timeTo: timeTo,
};
const groupFilter = target.group.filter;
return Promise.all([
this.zabbix.getHostAlerts(hostids, appids, options),
this.zabbix.getGroups(groupFilter)
])
.then(([triggers, groups]) => {
this.zabbix.getGroups(groupFilter),
]).then(([triggers, groups]) => {
return responseHandler.handleTriggersResponse(triggers, groups, timeRange);
});
} else {
@@ -536,7 +556,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
// replaceTemplateVars() builds regex-like string, so we should trim it.
const tagsFilterStr = tagsFilter.replace('/^', '').replace('$/', '');
const tags = utils.parseTags(tagsFilterStr);
tags.forEach(tag => {
tags.forEach((tag) => {
// Zabbix uses {"tag": "<tag>", "value": "<value>", "operator": "<operator>"} format, where 1 means Equal
tag.operator = 1;
});
@@ -556,9 +576,9 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
}
if (target.options?.minSeverity) {
let severities = [0, 1, 2, 3, 4, 5].filter(v => v >= target.options?.minSeverity);
let severities = [0, 1, 2, 3, 4, 5].filter((v) => v >= target.options?.minSeverity);
if (target.options?.severities) {
severities = severities.filter(v => target.options?.severities.includes(v));
severities = severities.filter((v) => target.options?.severities.includes(v));
}
problemsOptions.severities = severities;
}
@@ -567,27 +587,30 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
if (showProblems === ShowProblemTypes.History || target.options?.useTimeRange) {
problemsOptions.timeFrom = timeFrom;
problemsOptions.timeTo = timeTo;
getProblemsPromise = this.zabbix.getProblemsHistory(groupFilter, hostFilter, appFilter, proxyFilter, problemsOptions);
getProblemsPromise = this.zabbix.getProblemsHistory(
groupFilter,
hostFilter,
appFilter,
proxyFilter,
problemsOptions
);
} else {
getProblemsPromise = this.zabbix.getProblems(groupFilter, hostFilter, appFilter, proxyFilter, problemsOptions);
}
const problemsPromises = Promise.all([
getProblemsPromise,
getProxiesPromise
])
const problemsPromises = Promise.all([getProblemsPromise, getProxiesPromise])
.then(([problems, sourceProxies]) => {
proxies = _.keyBy(sourceProxies, 'proxyid');
return problems;
})
.then(problems => problemsHandler.setMaintenanceStatus(problems))
.then(problems => problemsHandler.setAckButtonStatus(problems, showAckButton))
.then(problems => problemsHandler.filterTriggersPre(problems, replacedTarget))
.then(problems => problemsHandler.sortProblems(problems, target))
.then(problems => problemsHandler.addTriggerDataSource(problems, target))
.then(problems => problemsHandler.addTriggerHostProxy(problems, proxies));
.then((problems) => problemsHandler.setMaintenanceStatus(problems))
.then((problems) => problemsHandler.setAckButtonStatus(problems, showAckButton))
.then((problems) => problemsHandler.filterTriggersPre(problems, replacedTarget))
.then((problems) => problemsHandler.sortProblems(problems, target))
.then((problems) => problemsHandler.addTriggerDataSource(problems, target))
.then((problems) => problemsHandler.addTriggerHostProxy(problems, proxies));
return problemsPromises.then(problems => {
return problemsPromises.then((problems) => {
const problemsDataFrame = problemsHandler.toDataFrame(problems);
return problemsDataFrame;
});
@@ -604,35 +627,35 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
message += `, DB connector type: ${dbConnectorStatus.dsType}`;
}
return {
status: "success",
title: "Success",
message: message
status: 'success',
title: 'Success',
message: message,
};
} catch (error) {
if (error instanceof ZabbixAPIError) {
return {
status: "error",
status: 'error',
title: error.message,
message: error.message
message: error.message,
};
} else if (error.data && error.data.message) {
return {
status: "error",
title: "Zabbix Client Error",
message: error.data.message
status: 'error',
title: 'Zabbix Client Error',
message: error.data.message,
};
} else if (typeof (error) === 'string') {
} else if (typeof error === 'string') {
return {
status: "error",
title: "Unknown Error",
message: error
status: 'error',
title: 'Unknown Error',
message: error,
};
} else {
console.log(error);
return {
status: "error",
title: "Connection failed",
message: "Could not connect to given url"
status: 'error',
title: 'Connection failed',
message: 'Could not connect to given url',
};
}
}
@@ -683,7 +706,13 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
resultPromise = this.zabbix.getItemTags(queryModel.group, queryModel.host, queryModel.itemTag);
break;
case VariableQueryTypes.Item:
resultPromise = this.zabbix.getItems(queryModel.group, queryModel.host, queryModel.application, null, queryModel.item);
resultPromise = this.zabbix.getItems(
queryModel.group,
queryModel.host,
queryModel.application,
null,
queryModel.item
);
break;
case VariableQueryTypes.ItemValues:
const range = options?.range;
@@ -694,7 +723,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
break;
}
return resultPromise.then(metrics => {
return resultPromise.then((metrics) => {
return _.map(metrics, formatMetric);
});
}
@@ -718,52 +747,63 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
// Annotations //
/////////////////
annotationQuery(options) {
async annotationRequest(request: DataQueryRequest<any>): Promise<DataQueryResponse> {
const targets = request.targets.filter((t) => t.fromAnnotations);
if (!targets.length) {
return Promise.resolve({ data: [] });
}
const events = await this.annotationQueryLegacy({ ...request, targets });
return { data: [toDataFrame(events)] };
}
annotationQueryLegacy(options) {
const timeRange = options.range || options.rangeRaw;
const timeFrom = Math.ceil(dateMath.parse(timeRange.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(timeRange.to) / 1000);
const annotation = options.annotation;
const annotation = options.targets[0];
// Show all triggers
const problemsOptions: any = {
value: annotation.showOkEvents ? ['0', '1'] : '1',
value: annotation.options.showOkEvents ? ['0', '1'] : '1',
valueFromEvent: true,
timeFrom,
timeTo,
};
if (annotation.minseverity) {
const severities = [0, 1, 2, 3, 4, 5].filter(v => v >= Number(annotation.minseverity));
if (annotation.options.minSeverity) {
const severities = [0, 1, 2, 3, 4, 5].filter((v) => v >= Number(annotation.options.minSeverity));
problemsOptions.severities = severities;
}
const groupFilter = this.replaceTemplateVars(annotation.group, {});
const hostFilter = this.replaceTemplateVars(annotation.host, {});
const appFilter = this.replaceTemplateVars(annotation.application, {});
const groupFilter = this.replaceTemplateVars(annotation.group.filter, {});
const hostFilter = this.replaceTemplateVars(annotation.host.filter, {});
const appFilter = this.replaceTemplateVars(annotation.application.filter, {});
const proxyFilter = undefined;
return this.zabbix.getProblemsHistory(groupFilter, hostFilter, appFilter, proxyFilter, problemsOptions)
.then(problems => {
return this.zabbix
.getProblemsHistory(groupFilter, hostFilter, appFilter, proxyFilter, problemsOptions)
.then((problems) => {
// Filter triggers by description
const problemName = this.replaceTemplateVars(annotation.trigger, {});
const problemName = this.replaceTemplateVars(annotation.trigger.filter, {});
if (utils.isRegex(problemName)) {
problems = _.filter(problems, p => {
problems = _.filter(problems, (p) => {
return utils.buildRegex(problemName).test(p.description);
});
} else if (problemName) {
problems = _.filter(problems, p => {
problems = _.filter(problems, (p) => {
return p.description === problemName;
});
}
// Hide acknowledged events if option enabled
if (annotation.hideAcknowledged) {
problems = _.filter(problems, p => {
problems = _.filter(problems, (p) => {
return !p.acknowledges?.length;
});
}
return _.map(problems, p => {
return _.map(problems, (p) => {
const formattedAcknowledges = utils.formatAcknowledges(p.acknowledges);
let annotationTags: string[] = [];
@@ -785,7 +825,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
// Replace template variables
replaceTargetVariables(target, options) {
const parts = ['group', 'host', 'application', 'itemTag', 'item'];
_.forEach(parts, p => {
_.forEach(parts, (p) => {
if (target[p] && target[p].filter) {
target[p].filter = this.replaceTemplateVars(target[p].filter, options.scopedVars);
}
@@ -799,8 +839,8 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
target.itemids = this.templateSrv.replace(target.itemids, options.scopedVars, zabbixItemIdsTemplateFormat);
}
_.forEach(target.functions, func => {
func.params = _.map(func.params, param => {
_.forEach(target.functions, (func) => {
func.params = _.map(func.params, (param) => {
if (typeof param === 'number') {
return +this.templateSrv.replace(param.toString(), options.scopedVars);
} else {
@@ -814,10 +854,7 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
const [timeFrom, timeTo] = timeRange;
const useTrendsFrom = Math.ceil(dateMath.parse('now-' + this.trendsFrom) / 1000);
const useTrendsRange = Math.ceil(utils.parseInterval(this.trendsRange) / 1000);
const useTrends = this.trends && (
(timeFrom < useTrendsFrom) ||
(timeTo - timeFrom > useTrendsRange)
);
const useTrends = this.trends && (timeFrom < useTrendsFrom || timeTo - timeFrom > useTrendsRange);
return useTrends;
}
@@ -826,23 +863,21 @@ export class ZabbixDatasource extends DataSourceApi<ZabbixMetricsQuery, ZabbixDS
return false;
}
return target.queryType === c.MODE_METRICS ||
target.queryType === c.MODE_ITEMID;
return target.queryType === c.MODE_METRICS || target.queryType === c.MODE_ITEMID;
};
isDBConnectionTarget = (target: any): boolean => {
return this.enableDirectDBConnection &&
(target.queryType === c.MODE_METRICS || target.queryType === c.MODE_ITEMID);
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 => {
const aggFuncDefs = _.filter(functionDefs, (func) => {
return _.includes(aggregationFunctions, func.def.name);
});
return _.map(aggFuncDefs, func => {
return _.map(aggFuncDefs, (func) => {
const funcInstance = metricFunctions.createFuncInstance(func.def, func.params);
return funcInstance.bindFunction(dataProcessor.metricFunctions);
});
@@ -850,7 +885,7 @@ function bindFunctionDefs(functionDefs, category) {
function getConsolidateBy(target) {
let consolidateBy;
const funcDef = _.find(target.functions, func => {
const funcDef = _.find(target.functions, (func) => {
return func.def.name === 'consolidateBy';
});
if (funcDef && funcDef.params && funcDef.params.length) {
@@ -862,7 +897,7 @@ function getConsolidateBy(target) {
function formatMetric(metricObj) {
return {
text: metricObj.name,
expandable: false
expandable: false,
};
}

View File

@@ -1,257 +0,0 @@
import coreModule from 'grafana/app/core/core_module';
import _ from 'lodash';
import $ from 'jquery';
import { react2AngularDirective } from './react2angular';
import { FunctionEditor } from './components/FunctionEditor';
/** @ngInject */
export function zabbixFunctionEditor($compile, templateSrv) {
const funcSpanTemplate = `
<zbx-function-editor
func="func"
onRemove="ctrl.handleRemoveFunction"
onMoveLeft="ctrl.handleMoveLeft"
onMoveRight="ctrl.handleMoveRight">
</zbx-function-editor>
<span>(</span>
`;
const paramTemplate =
'<input type="text" style="display:none"' + ' class="input-small tight-form-func-param"></input>';
return {
restrict: 'A',
link: function postLink($scope, elem) {
const $funcLink = $(funcSpanTemplate);
const ctrl = $scope.ctrl;
const func = $scope.func;
let scheduledRelink = false;
let paramCountAtLink = 0;
let cancelBlur = null;
ctrl.handleRemoveFunction = func => {
ctrl.removeFunction(func);
};
ctrl.handleMoveLeft = func => {
ctrl.moveFunction(func, -1);
};
ctrl.handleMoveRight = func => {
ctrl.moveFunction(func, 1);
};
function clickFuncParam(this: any, paramIndex) {
/*jshint validthis:true */
const $link = $(this);
const $comma = $link.prev('.comma');
const $input = $link.next();
$input.val(func.params[paramIndex]);
$comma.removeClass('query-part__last');
$link.hide();
$input.show();
$input.focus();
$input.select();
const typeahead = $input.data('typeahead');
if (typeahead) {
$input.val('');
typeahead.lookup();
}
}
function scheduledRelinkIfNeeded() {
if (paramCountAtLink === func.params.length) {
return;
}
if (!scheduledRelink) {
scheduledRelink = true;
setTimeout(() => {
relink();
scheduledRelink = false;
}, 200);
}
}
function paramDef(index) {
if (index < func.def.params.length) {
return func.def.params[index];
}
if ((_.last(func.def.params) as any).multiple) {
return _.assign({}, _.last(func.def.params), { optional: true });
}
return {};
}
function switchToLink(inputElem, paramIndex) {
/*jshint validthis:true */
const $input = $(inputElem);
clearTimeout(cancelBlur);
cancelBlur = null;
const $link = $input.prev();
const $comma = $link.prev('.comma');
const newValue = $input.val();
// remove optional empty params
if (newValue !== '' || paramDef(paramIndex).optional) {
func.updateParam(newValue, paramIndex);
$link.html(newValue ? templateSrv.highlightVariablesAsHtml(newValue) : '&nbsp;');
}
scheduledRelinkIfNeeded();
$scope.$apply(() => {
ctrl.targetChanged();
});
if ($link.hasClass('query-part__last') && newValue === '') {
$comma.addClass('query-part__last');
} else {
$link.removeClass('query-part__last');
}
$input.hide();
$link.show();
}
// this = input element
function inputBlur(this: any, paramIndex) {
/*jshint validthis:true */
const inputElem = this;
// happens long before the click event on the typeahead options
// need to have long delay because the blur
cancelBlur = setTimeout(() => {
switchToLink(inputElem, paramIndex);
}, 200);
}
function inputKeyPress(this: any, paramIndex, e) {
/*jshint validthis:true */
if (e.which === 13) {
$(this).blur();
}
}
function inputKeyDown(this: any) {
/*jshint validthis:true */
this.style.width = (3 + this.value.length) * 8 + 'px';
}
function addTypeahead($input, paramIndex) {
$input.attr('data-provide', 'typeahead');
let options = paramDef(paramIndex).options;
if (paramDef(paramIndex).type === 'int' || paramDef(paramIndex).type === 'float') {
options = _.map(options, val => {
return val.toString();
});
}
$input.typeahead({
source: options,
minLength: 0,
items: 20,
updater: value => {
$input.val(value);
switchToLink($input[0], paramIndex);
return value;
},
});
const typeahead = $input.data('typeahead');
typeahead.lookup = function() {
this.query = this.$element.val() || '';
return this.process(this.source);
};
}
function addElementsAndCompile() {
$funcLink.appendTo(elem);
const defParams: any = _.clone(func.def.params);
const lastParam: any = _.last(func.def.params);
while (func.params.length >= defParams.length && lastParam && lastParam.multiple) {
defParams.push(_.assign({}, lastParam, { optional: true }));
}
_.each(defParams, (param: any, index: number) => {
if (param.optional && func.params.length < index) {
return false;
}
let paramValue = templateSrv.highlightVariablesAsHtml(func.params[index]);
const hasValue = paramValue !== null && paramValue !== undefined;
const last = index >= func.params.length - 1 && param.optional && !hasValue;
if (last && param.multiple) {
paramValue = '+';
}
if (index > 0) {
$('<span class="comma' + (last ? ' query-part__last' : '') + '">,&nbsp; </span>').appendTo(elem);
}
const $paramLink = $(
'<a ng-click="" class="graphite-func-param-link' +
(last ? ' query-part__last' : '') +
'">' +
(hasValue ? paramValue : '&nbsp;') +
'</a>'
);
const $input = $(paramTemplate);
$input.attr('placeholder', param.name);
paramCountAtLink++;
$paramLink.appendTo(elem);
$input.appendTo(elem);
$input.blur(_.partial(inputBlur, index));
$input.keyup(inputKeyDown);
$input.keypress(_.partial(inputKeyPress, index));
$paramLink.click(_.partial(clickFuncParam, index));
if (param.options) {
addTypeahead($input, index);
}
return true;
});
$('<span>)</span>').appendTo(elem);
$compile(elem.contents())($scope);
}
function ifJustAddedFocusFirstParam() {
if ($scope.func.added) {
$scope.func.added = false;
setTimeout(() => {
elem
.find('.graphite-func-param-link')
.first()
.click();
}, 10);
}
}
function relink() {
elem.children().remove();
addElementsAndCompile();
ifJustAddedFocusFirstParam();
}
relink();
},
};
}
coreModule.directive('zabbixFunctionEditor', zabbixFunctionEditor);
react2AngularDirective('zbxFunctionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']);

View File

@@ -1,18 +1,19 @@
import _ from 'lodash';
import { FuncDef } from './types';
import { isNumeric } from './utils';
const index = [];
const categories = {
const index = {};
const categories: { [key: string]: FuncDef[] } = {
Transform: [],
Aggregate: [],
Filter: [],
Trends: [],
Time: [],
Alias: [],
Special: []
Special: [],
};
function addFuncDef(funcDef) {
function addFuncDef(funcDef: FuncDef) {
funcDef.params = funcDef.params || [];
funcDef.defaultParams = funcDef.defaultParams || [];
@@ -29,8 +30,8 @@ addFuncDef({
name: 'groupBy',
category: 'Transform',
params: [
{ name: 'interval', type: 'string'},
{ name: 'function', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] }
{ name: 'interval', type: 'string' },
{ name: 'function', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] },
],
defaultParams: ['1m', 'avg'],
});
@@ -38,18 +39,14 @@ addFuncDef({
addFuncDef({
name: 'scale',
category: 'Transform',
params: [
{ name: 'factor', type: 'float', options: [100, 0.01, 10, -1]}
],
params: [{ name: 'factor', type: 'float', options: [100, 0.01, 10, -1] }],
defaultParams: [100],
});
addFuncDef({
name: 'offset',
category: 'Transform',
params: [
{ name: 'delta', type: 'float', options: [-100, 100]}
],
params: [{ name: 'delta', type: 'float', options: [-100, 100] }],
defaultParams: [100],
});
@@ -70,18 +67,14 @@ addFuncDef({
addFuncDef({
name: 'movingAverage',
category: 'Transform',
params: [
{ name: 'factor', type: 'int', options: [6, 10, 60, 100, 600] }
],
params: [{ name: 'factor', type: 'int', options: [6, 10, 60, 100, 600] }],
defaultParams: [10],
});
addFuncDef({
name: 'exponentialMovingAverage',
category: 'Transform',
params: [
{ name: 'smoothing', type: 'float', options: [6, 10, 60, 100, 600] }
],
params: [{ name: 'smoothing', type: 'float', options: [6, 10, 60, 100, 600] }],
defaultParams: [0.2],
});
@@ -90,7 +83,7 @@ addFuncDef({
category: 'Transform',
params: [
{ name: 'interval', type: 'string' },
{ name: 'percent', type: 'float', options: [25, 50, 75, 90, 95, 99, 99.9] }
{ name: 'percent', type: 'float', options: [25, 50, 75, 90, 95, 99, 99.9] },
],
defaultParams: ['1m', 95],
});
@@ -98,27 +91,21 @@ addFuncDef({
addFuncDef({
name: 'removeAboveValue',
category: 'Transform',
params: [
{name: 'number', type: 'float'},
],
params: [{ name: 'number', type: 'float' }],
defaultParams: [0],
});
addFuncDef({
name: 'removeBelowValue',
category: 'Transform',
params: [
{name: 'number', type: 'float'},
],
params: [{ name: 'number', type: 'float' }],
defaultParams: [0],
});
addFuncDef({
name: 'transformNull',
category: 'Transform',
params: [
{name: 'number', type: 'float'}
],
params: [{ name: 'number', type: 'float' }],
defaultParams: [0],
});
@@ -129,7 +116,7 @@ addFuncDef({
category: 'Aggregate',
params: [
{ name: 'interval', type: 'string' },
{ name: 'function', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] }
{ name: 'function', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] },
],
defaultParams: ['1m', 'avg'],
});
@@ -146,7 +133,7 @@ addFuncDef({
category: 'Aggregate',
params: [
{ name: 'interval', type: 'string' },
{ name: 'percent', type: 'float', options: [25, 50, 75, 90, 95, 99, 99.9] }
{ name: 'percent', type: 'float', options: [25, 50, 75, 90, 95, 99, 99.9] },
],
defaultParams: ['1m', 95],
});
@@ -158,7 +145,7 @@ addFuncDef({
category: 'Filter',
params: [
{ name: 'number', type: 'int' },
{ name: 'value', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] }
{ name: 'value', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] },
],
defaultParams: [5, 'avg'],
});
@@ -168,7 +155,7 @@ addFuncDef({
category: 'Filter',
params: [
{ name: 'number', type: 'int' },
{ name: 'value', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] }
{ name: 'value', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count', 'median', 'first', 'last'] },
],
defaultParams: [5, 'avg'],
});
@@ -176,10 +163,8 @@ addFuncDef({
addFuncDef({
name: 'sortSeries',
category: 'Filter',
params: [
{ name: 'direction', type: 'string', options: ['asc', 'desc'] }
],
defaultParams: ['asc']
params: [{ name: 'direction', type: 'string', options: ['asc', 'desc'] }],
defaultParams: ['asc'],
});
// Trends
@@ -187,9 +172,7 @@ addFuncDef({
addFuncDef({
name: 'trendValue',
category: 'Trends',
params: [
{ name: 'type', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count'] }
],
params: [{ name: 'type', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count'] }],
defaultParams: ['avg'],
});
@@ -198,9 +181,7 @@ addFuncDef({
addFuncDef({
name: 'timeShift',
category: 'Time',
params: [
{ name: 'interval', type: 'string', options: ['24h', '7d', '1M', '+24h', '-24h']}
],
params: [{ name: 'interval', type: 'string', options: ['24h', '7d', '1M', '+24h', '-24h'] }],
defaultParams: ['24h'],
});
@@ -209,19 +190,15 @@ addFuncDef({
addFuncDef({
name: 'setAlias',
category: 'Alias',
params: [
{ name: 'alias', type: 'string' }
],
defaultParams: []
params: [{ name: 'alias', type: 'string' }],
defaultParams: [],
});
addFuncDef({
name: 'setAliasByRegex',
category: 'Alias',
params: [
{ name: 'aliasByRegex', type: 'string' }
],
defaultParams: []
params: [{ name: 'aliasByRegex', type: 'string' }],
defaultParams: [],
});
addFuncDef({
@@ -229,18 +206,16 @@ addFuncDef({
category: 'Alias',
params: [
{ name: 'regexp', type: 'string' },
{ name: 'newAlias', type: 'string' }
{ name: 'newAlias', type: 'string' },
],
defaultParams: ['/(.*)/', '$1']
defaultParams: ['/(.*)/', '$1'],
});
// Special
addFuncDef({
name: 'consolidateBy',
category: 'Special',
params: [
{ name: 'type', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count'] }
],
params: [{ name: 'type', type: 'string', options: ['avg', 'min', 'max', 'sum', 'count'] }],
defaultParams: ['avg'],
});
@@ -271,7 +246,6 @@ class FuncInstance {
bindFunction(metricFunctions) {
const func = metricFunctions[this.def.name];
if (func) {
// Bind function arguments
let bindedFunc = func;
let param;
@@ -279,8 +253,7 @@ class FuncInstance {
param = this.params[i];
// Convert numeric params
if (this.def.params[i].type === 'int' ||
this.def.params[i].type === 'float') {
if (this.def.params[i].type === 'int' || this.def.params[i].type === 'float') {
param = Number(param);
}
bindedFunc = _.partial(bindedFunc, param);
@@ -295,17 +268,13 @@ class FuncInstance {
const str = this.def.name + '(';
const parameters = _.map(this.params, (value, index) => {
const paramType = this.def.params[index].type;
if (paramType === 'int' ||
paramType === 'float' ||
paramType === 'value_or_series' ||
paramType === 'boolean') {
if (paramType === 'int' || paramType === 'float' || paramType === 'value_or_series' || paramType === 'boolean') {
return value;
} else if (paramType === 'int_or_interval' && isNumeric(value)) {
return value;
}
return "'" + value + "'";
});
if (metricExp) {
@@ -335,7 +304,7 @@ class FuncInstance {
if (strValue === '' && this.def.params[index].optional) {
this.params.splice(index, 1);
}else {
} else {
this.params[index] = strValue;
}

View File

@@ -9,9 +9,11 @@ import * as c from './constants';
export function isGrafana2target(target) {
if (!target.mode || target.mode === 0 || target.mode === 2) {
if ((target.hostFilter || target.itemFilter || target.downsampleFunction ||
(target.host && target.host.host)) &&
(target.item.filter === undefined && target.host.filter === undefined)) {
if (
(target.hostFilter || target.itemFilter || target.downsampleFunction || (target.host && target.host.host)) &&
target.item.filter === undefined &&
target.host.filter === undefined
) {
return true;
} else {
return false;
@@ -22,10 +24,10 @@ export function isGrafana2target(target) {
}
export function migrateFrom2To3version(target: ZabbixMetricsQuery) {
target.group.filter = target.group.name === "*" ? "/.*/" : target.group.name;
target.host.filter = target.host.name === "*" ? convertToRegex(target.hostFilter) : target.host.name;
target.application.filter = target.application.name === "*" ? "" : target.application.name;
target.item.filter = target.item.name === "All" ? convertToRegex(target.itemFilter) : target.item.name;
target.group.filter = target.group.name === '*' ? '/.*/' : target.group.name;
target.host.filter = target.host.name === '*' ? convertToRegex(target.hostFilter) : target.host.name;
target.application.filter = target.application.name === '*' ? '' : target.application.name;
target.item.filter = target.item.name === 'All' ? convertToRegex(target.itemFilter) : target.item.name;
return target;
}
@@ -77,6 +79,12 @@ function migrateApplications(target) {
}
}
function migrateSLAProperty(target) {
if (target.slaProperty?.property) {
target.slaProperty = target.slaProperty?.property;
}
}
export function migrate(target) {
target.resultFormat = target.resultFormat || 'time_series';
target = fixTargetGroup(target);
@@ -88,12 +96,13 @@ export function migrate(target) {
migrateSLA(target);
migrateProblemSort(target);
migrateApplications(target);
migrateSLAProperty(target);
return target;
}
function fixTargetGroup(target) {
if (target.group && Array.isArray(target.group)) {
target.group = { 'filter': "" };
target.group = { filter: '' };
}
return target;
}
@@ -128,7 +137,7 @@ export function migrateDSConfig(jsonData) {
}
if (oldVersion < 3) {
jsonData.timeout = (jsonData.timeout as string) === "" ? null : Number(jsonData.timeout as string);
jsonData.timeout = (jsonData.timeout as string) === '' ? null : Number(jsonData.timeout as string);
}
return jsonData;
@@ -143,3 +152,34 @@ function shouldMigrateDSConfig(jsonData): boolean {
}
return false;
}
const getDefaultAnnotationTarget = (json: any) => {
return {
group: { filter: json.group ?? '' },
host: { filter: json.host ?? '' },
application: { filter: json.application ?? '' },
trigger: { filter: json.trigger ?? '' },
options: {
minSeverity: json.minseverity ?? 0,
showOkEvents: json.showOkEvents ?? false,
hideAcknowledged: json.hideAcknowledged ?? false,
showHostname: json.showHostname ?? false,
},
};
};
export const prepareAnnotation = (json: any) => {
const defaultTarget = getDefaultAnnotationTarget(json);
json.target = {
...defaultTarget,
...json.target,
fromAnnotations: true,
options: {
...defaultTarget.options!,
...json.target?.options,
},
};
return json;
};

View File

@@ -1,25 +1,18 @@
import { DataSourcePlugin } from '@grafana/data';
import { loadPluginCss } from '@grafana/runtime';
import { ZabbixDatasource } from './datasource';
import { ZabbixQueryController } from './query.controller';
import { QueryEditor } from './components/QueryEditor';
import { ZabbixVariableQueryEditor } from './components/VariableQueryEditor';
import { ConfigEditor } from './components/ConfigEditor';
import './add-metric-function.directive';
import './metric-function-editor.directive';
class ZabbixAnnotationsQueryController {
static templateUrl = 'datasource-zabbix/partials/annotations.editor.html';
}
ZabbixQueryController.templateUrl = 'datasource-zabbix/partials/query.editor.html';
import '../sass/grafana-zabbix.dark.scss';
import '../sass/grafana-zabbix.light.scss';
loadPluginCss({
dark: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.dark.css',
light: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.light.css'
light: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.light.css',
});
export const plugin = new DataSourcePlugin(ZabbixDatasource)
.setConfigEditor(ConfigEditor)
.setQueryCtrl(ZabbixQueryController)
.setAnnotationQueryCtrl(ZabbixAnnotationsQueryController)
.setQueryEditor(QueryEditor)
.setVariableQueryEditor(ZabbixVariableQueryEditor);

View File

@@ -1,69 +0,0 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-10">Group</span>
<input type="text"
class="gf-form-input max-width-16"
ng-model="ctrl.annotation.group">
</input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Host</span>
<input type="text"
class="gf-form-input max-width-16"
ng-model="ctrl.annotation.host">
</input>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-10">Application</span>
<input type="text"
class="gf-form-input max-width-16"
ng-model="ctrl.annotation.application">
</input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Trigger</span>
<input type="text"
class="gf-form-input max-width-16"
ng-model="ctrl.annotation.trigger">
</input>
</div>
</div>
</div>
<div class="gf-form-group">
<h6>Options</h6>
<div class="gf-form">
<span class="gf-form-label width-12">Minimum severity</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto"
ng-init='ctrl.annotation.minseverity = ctrl.annotation.minseverity || 0'
ng-model='ctrl.annotation.minseverity'
ng-options="v as k for (k, v) in {
'Not classified': 0,
'Information': 1,
'Warning': 2,
'Average': 3,
'High': 4,
'Disaster': 5
}"
ng-change="render()">
</select>
</div>
</div>
<gf-form-switch class="gf-form" label-class="width-12"
label="Show OK events"
checked="ctrl.annotation.showOkEvents">
</gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-12"
label="Hide acknowledged events"
checked="ctrl.annotation.hideAcknowledged">
</gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-12"
label="Show hostname"
checked="ctrl.annotation.showHostname">
</gf-form-switch>
</div>

View File

@@ -1,396 +0,0 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<div class="gf-form-inline">
<div class="gf-form max-width-20">
<label class="gf-form-label width-7">Query Mode</label>
<div class="gf-form-select-wrapper max-width-20">
<select class="gf-form-input"
ng-change="ctrl.switchEditorMode(ctrl.target.queryType)"
ng-model="ctrl.target.queryType"
ng-options="m.queryType as m.text for m in ctrl.editorModes">
</select>
</div>
</div>
<div class="gf-form" ng-show="ctrl.target.queryType == editorMode.TEXT">
<label class="gf-form-label query-keyword width-7">Format As</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.resultFormat"
ng-options="f.value as f.text for f in ctrl.resultFormats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<!-- IT Service editor -->
<div class="gf-form-inline" ng-show="ctrl.target.queryType == editorMode.ITSERVICE">
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-7">IT Service</label>
<input type="text"
ng-model="ctrl.target.itServiceFilter"
bs-typeahead="ctrl.getITServices"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.itServiceFilter),
'zbx-regex': ctrl.isRegex(ctrl.target.itServiceFilter)
}">
</input>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Property</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input"
ng-change="ctrl.onTargetBlur()"
ng-model="ctrl.target.slaProperty"
ng-options="slaProperty.name for slaProperty in ctrl.slaPropertyList track by slaProperty.name">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Interval</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input"
ng-change="ctrl.onTargetBlur()"
ng-model="ctrl.target.slaInterval"
ng-options="i.value as i.text for i in ctrl.slaIntervals">
</select>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline"
ng-show="ctrl.target.queryType == editorMode.METRICS || ctrl.target.queryType == editorMode.TEXT || ctrl.target.queryType == editorMode.TRIGGERS || ctrl.target.queryType == editorMode.PROBLEMS">
<!-- Select Group -->
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-7">Group</label>
<input type="text"
ng-model="ctrl.target.group.filter"
bs-typeahead="ctrl.getGroupNames"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.group.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.group.filter)
}"></input>
</div>
<!-- Select Host -->
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-7">Host</label>
<input type="text"
ng-model="ctrl.target.host.filter"
bs-typeahead="ctrl.getHostNames"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.host.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.host.filter)
}">
</div>
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<label class="gf-form-label query-keyword width-7">Proxy</label>
<input type="text"
ng-model="ctrl.target.proxy.filter"
bs-typeahead="ctrl.getProxyNames"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input width-14"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.proxy.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.proxy.filter)
}">
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline"
ng-show="ctrl.target.queryType == editorMode.METRICS || ctrl.target.queryType == editorMode.TEXT || ctrl.target.queryType == editorMode.TRIGGERS || ctrl.target.queryType == editorMode.PROBLEMS">
<!-- Select Application -->
<div class="gf-form max-width-20" ng-show="!ctrl.appFilterDisabled()">
<label class="gf-form-label query-keyword width-7">Application</label>
<input type="text"
ng-model="ctrl.target.application.filter"
bs-typeahead="ctrl.getApplicationNames"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.application.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.application.filter)
}">
</div>
<!-- Select item tags -->
<div class="gf-form max-width-20" ng-show="ctrl.appFilterDisabled()">
<label class="gf-form-label query-keyword width-7">Item tag</label>
<input type="text"
ng-model="ctrl.target.itemTag.filter"
bs-typeahead="ctrl.getItemTags"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.itemTag.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.itemTag.filter)
}">
</div>
<!-- Select Item -->
<div class="gf-form max-width-20"
ng-show="ctrl.target.queryType == editorMode.METRICS || ctrl.target.queryType == editorMode.TEXT">
<label class="gf-form-label query-keyword width-7">Item</label>
<input type="text"
ng-model="ctrl.target.item.filter"
bs-typeahead="ctrl.getItemNames"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.item.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.item.filter)
}">
</div>
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<label class="gf-form-label query-keyword width-7">Problem</label>
<input type="text"
ng-model="ctrl.target.trigger.filter"
ng-blur="ctrl.onTargetBlur()"
placeholder="Problem name"
class="gf-form-input"
ng-style="ctrl.target.trigger.style"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.trigger.filter),
'zbx-regex': ctrl.isRegex(ctrl.target.trigger.filter)
}">
</div>
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<label class="gf-form-label query-keyword width-7">Tags</label>
<input type="text" class="gf-form-input width-14"
ng-model="ctrl.target.tags.filter"
ng-blur="ctrl.onTargetBlur()"
placeholder="tag1:value1, tag2:value2">
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline"
ng-show="ctrl.target.queryType == editorMode.TRIGGERS || ctrl.target.queryType == editorMode.PROBLEMS">
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<label class="gf-form-label query-keyword width-7">Show</label>
<div class="gf-form-select-wrapper max-width-20">
<select class="gf-form-input"
ng-model="ctrl.target.showProblems"
ng-options="v.value as v.text for v in ctrl.showProblemsOptions"
ng-change="ctrl.onTargetBlur()">
</select>
</div>
</div>
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<label class="gf-form-label query-keyword width-7">Min severity</label>
<div class="gf-form-select-wrapper max-width-20">
<select class="gf-form-input"
ng-model="ctrl.target.options.minSeverity"
ng-options="v.val as v.text for v in ctrl.severityOptions"
ng-change="ctrl.onTargetBlur()">
</select>
</div>
</div>
<div class="gf-form max-width-20" ng-show="ctrl.target.queryType == editorMode.TRIGGERS">
<label class="gf-form-label query-keyword width-7">Min Severity</label>
<div class="gf-form-select-wrapper width-14">
<select class="gf-form-input"
ng-change="ctrl.onTargetBlur()"
ng-model="ctrl.target.triggers.minSeverity"
ng-options="s.val as s.text for s in ctrl.severityOptions">
</select>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<!-- Text mode options -->
<div class="gf-form-inline" ng-show="ctrl.target.queryType == editorMode.TEXT">
<!-- Text metric regex -->
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-7">Text filter</label>
<input type="text"
class="gf-form-input"
ng-model="ctrl.target.textFilter"
spellcheck='false'
placeholder="Text filter (regex)"
ng-blur="ctrl.onTargetBlur()">
</div>
<gf-form-switch class="gf-form" label="Use capture groups" checked="ctrl.target.useCaptureGroups"
on-change="ctrl.onTargetBlur()">
</gf-form-switch>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<!-- Item IDs editor mode -->
<div class="gf-form-inline" ng-show="ctrl.target.queryType == editorMode.ITEMID">
<div class="gf-form max-width-20">
<label class="gf-form-label query-keyword width-7">Item IDs</label>
<input type="text"
ng-model="ctrl.target.itemids"
bs-typeahead="ctrl.getVariables"
ng-blur="ctrl.onTargetBlur()"
data-min-length=0
data-items=100
class="gf-form-input"
ng-class="{
'zbx-variable': ctrl.isVariable(ctrl.target.itServiceFilter),
'zbx-regex': ctrl.isRegex(ctrl.target.itServiceFilter)
}">
</input>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<!-- Metric processing functions -->
<div class="gf-form-inline"
ng-show="ctrl.target.queryType == editorMode.METRICS || ctrl.target.queryType == editorMode.ITEMID || ctrl.target.queryType == editorMode.ITSERVICE">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Functions</label>
</div>
<div ng-repeat="func in ctrl.target.functions" class="gf-form">
<span zabbix-function-editor class="gf-form-label query-part" ng-hide="func.hidden"></span>
</div>
<div class="gf-form dropdown" add-metric-function>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form gf-form--grow">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="ctrl.toggleQueryOptions()">
<i class="fa fa-caret-down" ng-show="ctrl.showQueryOptions"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showQueryOptions"></i>
{{ctrl.queryOptionsText}}
</a>
</label>
</div>
<!-- Query options -->
<div class="gf-form-group offset-width-7" ng-if="ctrl.showQueryOptions">
<div ng-hide="ctrl.target.queryType == editorMode.TRIGGERS || ctrl.target.queryType == editorMode.PROBLEMS">
<gf-form-switch class="gf-form" label-class="width-12"
label="Show disabled items"
checked="ctrl.target.options.showDisabledItems"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-12"
label="Use Zabbix value mapping"
checked="ctrl.target.options.useZabbixValueMapping"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-12"
label="Disable data alignment"
checked="ctrl.target.options.disableDataAlignment"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
</div>
<div class="gf-form-group offset-width-7"
ng-show="ctrl.target.queryType === editorMode.TEXT && ctrl.target.resultFormat === 'table'">
<div class="gf-form">
<gf-form-switch class="gf-form" label-class="width-12"
label="Skip empty values"
checked="ctrl.target.options.skipEmptyValues"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
</div>
</div>
<div class="gf-form-group"
ng-show="ctrl.target.queryType == editorMode.PROBLEMS || ctrl.target.queryType == editorMode.TRIGGERS">
<gf-form-switch class="gf-form" ng-show="ctrl.target.queryType == editorMode.TRIGGERS"
label-class="width-9"
label="Count"
checked="ctrl.target.triggers.count"
on-change="ctrl.onTargetBlur()">
</gf-form-switch>
<div class="gf-form"
ng-show="ctrl.target.queryType == editorMode.PROBLEMS || ctrl.target.queryType == editorMode.TRIGGERS">
<label class="gf-form-label width-9">Acknowledged</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input"
ng-change="ctrl.onQueryOptionChange()"
ng-model="ctrl.target.options.acknowledged"
ng-options="a.value as a.text for a in ctrl.ackFilters">
</select>
</div>
</div>
<div ng-show="ctrl.target.queryType == editorMode.PROBLEMS">
<div class="gf-form">
<label class="gf-form-label width-9">Sort by</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input"
ng-model="ctrl.target.options.sortProblems"
ng-options="f.value as f.text for f in ctrl.sortByFields"
ng-change="ctrl.onQueryOptionChange()">
</select>
</div>
</div>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Use time range"
checked="ctrl.target.options.useTimeRange"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Hosts in maintenance"
checked="ctrl.target.options.hostsInMaintenance"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Host proxy"
checked="ctrl.target.options.hostProxy"
on-change="ctrl.onQueryOptionChange()">
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-9">Limit triggers</label>
<input class="gf-form-input width-5"
type="number" placeholder="100"
ng-model="ctrl.target.options.limit"
ng-model-onblur ng-change="ctrl.onQueryOptionChange()">
</div>
</div>
</div>
</div>
</query-editor-row>

View File

@@ -1,572 +0,0 @@
import { QueryCtrl } from 'grafana/app/plugins/sdk';
import _ from 'lodash';
import * as c from './constants';
import * as utils from './utils';
import { itemTagToString } from './utils';
import * as metricFunctions from './metricFunctions';
import * as migrations from './migrations';
import { ShowProblemTypes, ZBXItem, ZBXItemTag } from './types';
import { CURRENT_SCHEMA_VERSION } from '../panel-triggers/migrations';
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
function getTargetDefaults() {
return {
queryType: c.MODE_METRICS,
group: { 'filter': "" },
host: { 'filter': "" },
application: { 'filter': "" },
itemTag: { 'filter': "" },
item: { 'filter': "" },
functions: [],
triggers: {
'count': true,
'minSeverity': 3,
'acknowledged': 2
},
trigger: { filter: "" },
tags: { filter: "" },
proxy: { filter: "" },
options: {
showDisabledItems: false,
skipEmptyValues: false,
disableDataAlignment: false,
useZabbixValueMapping: false,
},
table: {
'skipEmptyValues': false
},
};
}
function getSLATargetDefaults() {
return {
slaProperty: { name: "SLA", property: "sla" },
slaInterval: 'none',
};
}
function getProblemsTargetDefaults() {
return {
showProblems: ShowProblemTypes.Problems,
options: {
minSeverity: 0,
sortProblems: 'default',
acknowledged: 2,
hostsInMaintenance: false,
hostProxy: false,
limit: c.DEFAULT_ZABBIX_PROBLEMS_LIMIT,
},
};
}
function getSeverityOptions() {
return c.TRIGGER_SEVERITY;
}
function mapSeverityOptionsFromPanel(severityOptions: any[]) {
if (!severityOptions) {
return [0, 1, 2, 3, 4, 5];
}
const severities = [];
for (const sevOption of severityOptions) {
if (sevOption.show) {
severities.push(sevOption.priority);
}
}
return severities;
}
export class ZabbixQueryController extends QueryCtrl {
static templateUrl: string;
zabbix: any;
replaceTemplateVars: any;
templateSrv: TemplateSrv;
editorModes: Array<{ value: string; text: string; queryType: string; }>;
slaPropertyList: Array<{ name: string; property: string; }>;
slaIntervals: Array<{ text: string; value: string; }>;
ackFilters: Array<{ text: string; value: number; }>;
problemAckFilters: string[];
sortByFields: Array<{ text: string; value: string; }>;
showEventsFields: Array<{ text: string; value: number[]; } | { text: string; value: number; }>;
showProblemsOptions: Array<{ text: string; value: string; }>;
resultFormats: Array<{ text: string; value: string; }>;
severityOptions: Array<{ val: number; text: string; }>;
getGroupNames: (...args: any[]) => any;
getHostNames: (...args: any[]) => any;
getApplicationNames: (...args: any[]) => any;
getItemNames: (...args: any[]) => any;
getITServices: (...args: any[]) => any;
getProxyNames: (...args: any[]) => any;
getVariables: (...args: any[]) => any;
init: () => void;
queryOptionsText: string;
metric: any;
showQueryOptions: boolean;
oldTarget: any;
/** @ngInject */
constructor($scope, $injector, $rootScope) {
super($scope, $injector);
this.zabbix = this.datasource.zabbix;
// Use custom format for template variables
this.replaceTemplateVars = this.datasource.replaceTemplateVars;
this.templateSrv = getTemplateSrv();
this.editorModes = [
{ value: 'num', text: 'Metrics', queryType: c.MODE_METRICS },
{ value: 'text', text: 'Text', queryType: c.MODE_TEXT },
{ value: 'itservice', text: 'IT Services', queryType: c.MODE_ITSERVICE },
{ value: 'itemid', text: 'Item ID', queryType: c.MODE_ITEMID },
{ value: 'triggers', text: 'Triggers', queryType: c.MODE_TRIGGERS },
{ value: 'problems', text: 'Problems', queryType: c.MODE_PROBLEMS },
];
this.$scope.editorMode = {
METRICS: c.MODE_METRICS,
TEXT: c.MODE_TEXT,
ITSERVICE: c.MODE_ITSERVICE,
ITEMID: c.MODE_ITEMID,
TRIGGERS: c.MODE_TRIGGERS,
PROBLEMS: c.MODE_PROBLEMS,
};
this.slaPropertyList = [
{ name: "Status", property: "status" },
{ name: "SLA", property: "sla" },
{ name: "OK time", property: "okTime" },
{ name: "Problem time", property: "problemTime" },
{ name: "Down time", property: "downtimeTime" }
];
this.slaIntervals = [
{ text: 'No interval', value: 'none' },
{ text: 'Auto', value: 'auto' },
{ text: '1 hour', value: '1h' },
{ text: '12 hours', value: '12h' },
{ text: '24 hours', value: '1d' },
{ text: '1 week', value: '1w' },
{ text: '1 month', value: '1M' },
];
this.ackFilters = [
{ text: 'all triggers', value: 2 },
{ text: 'unacknowledged', value: 0 },
{ text: 'acknowledged', value: 1 },
];
this.problemAckFilters = [
'all triggers',
'unacknowledged',
'acknowledged'
];
this.sortByFields = [
{ text: 'Default', value: 'default' },
{ text: 'Last change', value: 'lastchange' },
{ text: 'Severity', value: 'severity' },
];
this.showEventsFields = [
{ text: 'All', value: [0, 1] },
{ text: 'OK', value: [0] },
{ text: 'Problems', value: 1 }
];
this.showProblemsOptions = [
{ text: 'Problems', value: 'problems' },
{ text: 'Recent problems', value: 'recent' },
{ text: 'History', value: 'history' },
];
this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
this.severityOptions = getSeverityOptions();
// Map functions for bs-typeahead
this.getGroupNames = _.bind(this.getMetricNames, this, 'groupList');
this.getHostNames = _.bind(this.getMetricNames, this, 'hostList', true);
this.getApplicationNames = _.bind(this.getMetricNames, this, 'appList');
this.getItemNames = _.bind(this.getMetricNames, this, 'itemList');
this.getITServices = _.bind(this.getMetricNames, this, 'itServiceList');
this.getProxyNames = _.bind(this.getMetricNames, this, 'proxyList');
this.getVariables = _.bind(this.getTemplateVariables, this);
// Update metric suggestion when template variable was changed
$rootScope.$on('template-variable-value-updated', () => this.onVariableChange());
// Update metrics when item selected from dropdown
$scope.$on('typeahead-updated', () => {
this.onTargetBlur();
});
this.init = () => {
let target = this.target;
// Migrate old targets
target = migrations.migrate(target);
this.refresh();
const scopeDefaults = {
metric: {},
oldTarget: _.cloneDeep(this.target),
queryOptionsText: this.renderQueryOptionsText()
};
_.defaults(this, scopeDefaults);
// Load default values
const targetDefaults = getTargetDefaults();
_.defaultsDeep(target, targetDefaults);
this.initDefaultQueryMode(target);
if (this.panel.type === c.ZABBIX_PROBLEMS_PANEL_ID) {
target.queryType = c.MODE_PROBLEMS;
target.options.severities = mapSeverityOptionsFromPanel(this.panel.triggerSeverity);
}
// Create function instances from saved JSON
target.functions = _.map(target.functions, func => {
return metricFunctions.createFuncInstance(func.def, func.params);
});
if (target.queryType === c.MODE_ITSERVICE) {
_.defaultsDeep(target, getSLATargetDefaults());
}
if (target.queryType === c.MODE_PROBLEMS) {
_.defaultsDeep(target, getProblemsTargetDefaults());
}
if (target.queryType === c.MODE_METRICS ||
target.queryType === c.MODE_TEXT ||
target.queryType === c.MODE_TRIGGERS ||
target.queryType === c.MODE_PROBLEMS) {
this.initFilters();
} else if (target.queryType === c.MODE_ITSERVICE) {
this.suggestITServices();
}
};
// Update panel schema version to prevent unnecessary migrations
if (this.panel.type === c.ZABBIX_PROBLEMS_PANEL_ID) {
this.panel.schemaVersion = CURRENT_SCHEMA_VERSION;
}
this.init();
this.queryOptionsText = this.renderQueryOptionsText();
}
initFilters() {
const mode = _.find(this.editorModes, { 'queryType': this.target.queryType });
const itemtype = mode ? mode.value : null;
const promises = [
this.suggestGroups(),
this.suggestHosts(),
this.suggestApps(),
];
if (this.target.queryType === c.MODE_METRICS || this.target.queryType === c.MODE_TEXT) {
promises.push(this.suggestItems(itemtype));
}
if (this.target.queryType === c.MODE_PROBLEMS) {
promises.push(this.suggestProxies());
}
return Promise.all(promises).then(() => {
if (this.zabbix.isZabbix54OrHigher()) {
this.suggestItemTags()
.then(() => this.$scope.$apply());
}
});
}
initDefaultQueryMode(target) {
if (!(target.queryType === c.MODE_METRICS ||
target.queryType === c.MODE_TEXT ||
target.queryType === c.MODE_ITSERVICE ||
target.queryType === c.MODE_ITEMID ||
target.queryType === c.MODE_TRIGGERS ||
target.queryType === c.MODE_PROBLEMS)) {
target.queryType = c.MODE_METRICS;
}
}
// Get list of metric names for bs-typeahead directive
getMetricNames(metricList, addAllValue) {
const metrics = _.uniq(_.map(this.metric[metricList], 'name'));
// Add template variables
_.forEach(this.templateSrv.getVariables(), variable => {
metrics.unshift('$' + variable.name);
});
if (addAllValue) {
metrics.unshift('/.*/');
}
return metrics;
}
getItemTags = () => {
if (!this.metric?.tagList) {
return [];
}
const tags = this.metric.tagList.map(t => itemTagToString(t));
// Add template variables
_.forEach(this.templateSrv.getVariables(), variable => {
tags.unshift('$' + variable.name);
});
return tags;
};
getTemplateVariables() {
return _.map(this.templateSrv.getVariables(), variable => {
return '$' + variable.name;
});
}
isZabbix54OrHigher() {
return this.zabbix.isZabbix54OrHigher();
}
suggestGroups() {
return this.zabbix.getAllGroups()
.then(groups => {
this.metric.groupList = groups;
return groups;
});
}
suggestHosts() {
const groupFilter = this.replaceTemplateVars(this.target.group.filter);
return this.zabbix.getAllHosts(groupFilter)
.then(hosts => {
this.metric.hostList = hosts;
return hosts;
});
}
suggestApps() {
const groupFilter = this.replaceTemplateVars(this.target.group.filter);
const hostFilter = this.replaceTemplateVars(this.target.host.filter);
return this.zabbix.getAllApps(groupFilter, hostFilter)
.then(apps => {
this.metric.appList = apps;
return apps;
});
}
suggestItems(itemtype = 'num') {
const groupFilter = this.replaceTemplateVars(this.target.group.filter);
const hostFilter = this.replaceTemplateVars(this.target.host.filter);
const appFilter = this.replaceTemplateVars(this.target.application.filter);
const itemTagFilter = this.replaceTemplateVars(this.target.itemTag.filter);
const options = {
itemtype: itemtype,
showDisabledItems: this.target.options.showDisabledItems
};
return this.zabbix
.getAllItems(groupFilter, hostFilter, appFilter, itemTagFilter, options)
.then(items => {
this.metric.itemList = items;
return items;
});
}
async suggestItemTags() {
const groupFilter = this.replaceTemplateVars(this.target.group.filter);
const hostFilter = this.replaceTemplateVars(this.target.host.filter);
const items = await this.zabbix.getAllItems(groupFilter, hostFilter, null, null, {});
const tags: ZBXItemTag[] = _.flatten(items.map((item: ZBXItem) => {
if (item.tags) {
return item.tags;
} else {
return [];
}
}));
this.metric.tagList = _.uniqBy(tags, t => t.tag + t.value || '');
}
suggestITServices() {
return this.zabbix.getITService()
.then(itservices => {
this.metric.itServiceList = itservices;
return itservices;
});
}
suggestProxies() {
return this.zabbix.getProxies()
.then(response => {
const proxies = _.map(response, 'host');
this.metric.proxyList = proxies;
return proxies;
});
}
isRegex(str) {
return utils.isRegex(str);
}
isVariable(str) {
return utils.isTemplateVariable(str, this.templateSrv.getVariables());
}
onTargetBlur() {
const newTarget = _.cloneDeep(this.target);
if (!_.isEqual(this.oldTarget, this.target)) {
this.oldTarget = newTarget;
this.targetChanged();
}
}
onVariableChange() {
if (this.isContainsVariables()) {
this.targetChanged();
}
}
/**
* Check query for template variables
*/
isContainsVariables() {
return _.some(['group', 'host', 'application'], field => {
if (this.target[field] && this.target[field].filter) {
return utils.isTemplateVariable(this.target[field].filter, this.templateSrv.getVariables());
} else {
return false;
}
});
}
parseTarget() {
// Parse target
}
// Validate target and set validation info
validateTarget() {
// validate
}
targetChanged() {
this.initFilters();
this.parseTarget();
this.refresh();
}
addFunction(funcDef) {
const newFunc = metricFunctions.createFuncInstance(funcDef);
newFunc.added = true;
this.target.functions.push(newFunc);
this.moveAliasFuncLast();
if (newFunc.params.length && newFunc.added ||
newFunc.def.params.length === 0) {
this.targetChanged();
}
}
removeFunction(func) {
this.target.functions = _.without(this.target.functions, func);
this.targetChanged();
}
moveFunction(func, offset) {
const index = this.target.functions.indexOf(func);
// @ts-ignore
_.move(this.target.functions, index, index + offset);
this.targetChanged();
}
moveAliasFuncLast() {
const aliasFunc = _.find(this.target.functions, func => {
return func.def.category === 'Alias';
});
if (aliasFunc) {
this.target.functions = _.without(this.target.functions, aliasFunc);
this.target.functions.push(aliasFunc);
}
}
toggleQueryOptions() {
this.showQueryOptions = !this.showQueryOptions;
}
onQueryOptionChange() {
this.queryOptionsText = this.renderQueryOptionsText();
this.onTargetBlur();
}
renderQueryOptionsText() {
const metricOptionsMap = {
showDisabledItems: "Show disabled items",
disableDataAlignment: "Disable data alignment",
useZabbixValueMapping: "Use Zabbix value mapping",
};
const problemsOptionsMap = {
sortProblems: "Sort problems",
acknowledged: "Acknowledged",
skipEmptyValues: "Skip empty values",
hostsInMaintenance: "Show hosts in maintenance",
limit: "Limit problems",
hostProxy: "Show proxy",
useTimeRange: "Use time range",
};
let optionsMap = {};
if (this.target.queryType === c.MODE_METRICS) {
optionsMap = metricOptionsMap;
} else if (this.target.queryType === c.MODE_PROBLEMS || this.target.queryType === c.MODE_TRIGGERS) {
optionsMap = problemsOptionsMap;
}
const options = [];
_.forOwn(this.target.options, (value, key) => {
if (value && optionsMap[key]) {
if (value === true) {
// Show only option name (if enabled) for boolean options
options.push(optionsMap[key]);
} else {
// Show "option = value" for another options
let optionValue = value;
if (value && value.text) {
optionValue = value.text;
} else if (value && value.value) {
optionValue = value.value;
}
options.push(optionsMap[key] + " = " + optionValue);
}
}
});
return "Options: " + options.join(', ');
}
/**
* Switch query editor to specified mode.
* Modes:
* 0 - items
* 1 - IT services
* 2 - Text metrics
*/
switchEditorMode(mode) {
this.target.queryType = mode;
this.queryOptionsText = this.renderQueryOptionsText();
this.init();
this.targetChanged();
}
appFilterDisabled() {
return !this.zabbix.supportsApplications();
}
}

View File

@@ -1,10 +0,0 @@
import coreModule from 'grafana/app/core/core_module';
export function react2AngularDirective(name: string, component: any, options: any) {
coreModule.directive(name, [
'reactDirective',
reactDirective => {
return reactDirective(component, options);
},
]);
}

View File

@@ -43,25 +43,25 @@ function convertHistory(history, items, addHostName, convertPointCallback) {
const hosts = _.uniqBy(_.flatten(_.map(items, 'hosts')), 'hostid'); //uniqBy is needed to deduplicate
return _.map(grouped_history, (hist, itemid) => {
const item = _.find(items, { 'itemid': itemid }) as any;
const item = _.find(items, { itemid: itemid }) as any;
let alias = item.name;
// Add scopedVars for using in alias functions
const scopedVars: any = {
'__zbx_item': { value: item.name },
'__zbx_item_name': { value: item.name },
'__zbx_item_key': { value: item.key_ },
'__zbx_item_interval': { value: item.delay },
__zbx_item: { value: item.name },
__zbx_item_name: { value: item.name },
__zbx_item_key: { value: item.key_ },
__zbx_item_interval: { value: item.delay },
};
if (_.keys(hosts).length > 0) {
const host = _.find(hosts, { 'hostid': item.hostid });
const host = _.find(hosts, { hostid: item.hostid });
scopedVars['__zbx_host'] = { value: host.host };
scopedVars['__zbx_host_name'] = { value: host.name };
// Only add host when multiple hosts selected
if (_.keys(hosts).length > 1 && addHostName) {
alias = host.name + ": " + alias;
alias = host.name + ': ' + alias;
}
}
@@ -69,28 +69,33 @@ function convertHistory(history, items, addHostName, convertPointCallback) {
target: alias,
datapoints: _.map(hist, convertPointCallback),
scopedVars,
item
item,
};
});
}
export function seriesToDataFrame(timeseries, target: ZabbixMetricsQuery, valueMappings?: any[], fieldType?: FieldType): MutableDataFrame {
export function seriesToDataFrame(
timeseries,
target: ZabbixMetricsQuery,
valueMappings?: any[],
fieldType?: FieldType
): MutableDataFrame {
const { datapoints, scopedVars, target: seriesName, item } = timeseries;
const timeFiled: Field = {
name: TIME_SERIES_TIME_FIELD_NAME,
type: FieldType.time,
config: {
custom: {}
custom: {},
},
values: new ArrayVector<number>(datapoints.map(p => p[c.DATAPOINT_TS])),
values: new ArrayVector<number>(datapoints.map((p) => p[c.DATAPOINT_TS])),
};
let values: ArrayVector<number> | ArrayVector<string>;
if (fieldType === FieldType.string) {
values = new ArrayVector<string>(datapoints.map(p => p[c.DATAPOINT_VALUE]));
values = new ArrayVector<string>(datapoints.map((p) => p[c.DATAPOINT_VALUE]));
} else {
values = new ArrayVector<number>(datapoints.map(p => p[c.DATAPOINT_VALUE]));
values = new ArrayVector<number>(datapoints.map((p) => p[c.DATAPOINT_VALUE]));
}
const valueFiled: Field = {
@@ -99,7 +104,7 @@ export function seriesToDataFrame(timeseries, target: ZabbixMetricsQuery, valueM
labels: {},
config: {
displayNameFromDS: seriesName,
custom: {}
custom: {},
},
values,
};
@@ -179,7 +184,7 @@ export function dataResponseToTimeSeries(response: DataFrameJSON[], items, reque
}
const itemid = field.name;
const item = _.find(items, { 'itemid': itemid });
const item = _.find(items, { itemid: itemid });
// Convert interval to nanoseconds in order to unmarshall it on the backend to time.Duration
let interval = request.intervalMs * 1000000;
@@ -201,7 +206,7 @@ export function dataResponseToTimeSeries(response: DataFrameJSON[], items, reque
name: seriesName,
item,
interval,
}
},
};
series.push(timeSeriesData);
@@ -263,7 +268,7 @@ export function itServiceResponseToTimeSeries(response: any, interval) {
name: s.target,
interval: null,
item: {},
}
},
};
series.push(timeSeriesData);
@@ -277,13 +282,13 @@ export function isConvertibleToWide(data: DataFrame[]): boolean {
return false;
}
const first = data[0].fields.find(f => f.type === FieldType.time);
const first = data[0].fields.find((f) => f.type === FieldType.time);
if (!first) {
return false;
}
for (let i = 1; i < data.length; i++) {
const timeField = data[i].fields.find(f => f.type === FieldType.time);
const timeField = data[i].fields.find((f) => f.type === FieldType.time);
for (let j = 0; j < Math.min(data.length, 2); j++) {
if (timeField.values.get(j) !== first.values.get(j)) {
@@ -301,9 +306,9 @@ export function alignFrames(data: MutableDataFrame[]): MutableDataFrame[] {
}
// Get oldest time stamp for all frames
let minTimestamp = data[0].fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME).values.get(0);
let minTimestamp = data[0].fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME).values.get(0);
for (let i = 0; i < data.length; i++) {
const timeField = data[i].fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME);
const timeField = data[i].fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const firstTs = timeField.values.get(0);
if (firstTs < minTimestamp) {
minTimestamp = firstTs;
@@ -312,8 +317,8 @@ export function alignFrames(data: MutableDataFrame[]): MutableDataFrame[] {
for (let i = 0; i < data.length; i++) {
const frame = data[i];
const timeField = frame.fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME);
const valueField = frame.fields.find(f => f.name === TIME_SERIES_VALUE_FIELD_NAME);
const timeField = frame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const valueField = frame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
const firstTs = timeField.values.get(0);
if (firstTs > minTimestamp) {
@@ -340,7 +345,7 @@ export function alignFrames(data: MutableDataFrame[]): MutableDataFrame[] {
export function convertToWide(data: MutableDataFrame[]): DataFrame[] {
const maxLengthIndex = getLongestFrame(data);
const timeField = data[maxLengthIndex].fields.find(f => f.type === FieldType.time);
const timeField = data[maxLengthIndex].fields.find((f) => f.type === FieldType.time);
if (!timeField) {
return [];
}
@@ -348,7 +353,7 @@ export function convertToWide(data: MutableDataFrame[]): DataFrame[] {
const fields: MutableField[] = [timeField];
for (let i = 0; i < data.length; i++) {
const valueField = data[i].fields.find(f => f.name === TIME_SERIES_VALUE_FIELD_NAME);
const valueField = data[i].fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
if (!valueField) {
continue;
}
@@ -363,7 +368,7 @@ export function convertToWide(data: MutableDataFrame[]): DataFrame[] {
}
const frame: DataFrame = {
name: "wide",
name: 'wide',
fields,
length: timeField.values.length,
};
@@ -375,7 +380,7 @@ function getLongestFrame(data: MutableDataFrame[]): number {
let maxLengthIndex = 0;
let maxLength = 0;
for (let i = 0; i < data.length; i++) {
const timeField = data[i].fields.find(f => f.type === FieldType.time);
const timeField = data[i].fields.find((f) => f.type === FieldType.time);
if (timeField.values.length > maxLength) {
maxLength = timeField.values.length;
maxLengthIndex = i;
@@ -387,8 +392,8 @@ function getLongestFrame(data: MutableDataFrame[]): number {
function sortTimeseries(timeseries) {
// Sort trend data, issue #202
_.forEach(timeseries, series => {
series.datapoints = _.sortBy(series.datapoints, point => point[c.DATAPOINT_TS]);
_.forEach(timeseries, (series) => {
series.datapoints = _.sortBy(series.datapoints, (point) => point[c.DATAPOINT_TS]);
});
return timeseries;
}
@@ -430,11 +435,9 @@ function handleHistoryAsTable(history, items, target) {
}
let host: any = _.first(item.hosts);
host = host ? host.name : "";
host = host ? host.name : '';
table.rows.push([
host, item.name, item.key_, lastValue
]);
table.rows.push([host, item.name, item.key_, lastValue]);
});
return table;
@@ -448,10 +451,7 @@ function convertText(target, point) {
value = extractText(point.value, target.textFilter, target.useCaptureGroups);
}
return [
value,
point.clock * 1000 + Math.round(point.ns / 1000000)
];
return [value, point.clock * 1000 + Math.round(point.ns / 1000000)];
}
function extractText(str, pattern, useCaptureGroups) {
@@ -464,31 +464,29 @@ function extractText(str, pattern, useCaptureGroups) {
return extractedValue[0];
}
}
return "";
return '';
}
function handleSLAResponse(itservice, slaProperty, slaObject) {
const targetSLA = slaObject[itservice.serviceid].sla;
if (slaProperty.property === 'status') {
if (slaProperty === 'status') {
const targetStatus = parseInt(slaObject[itservice.serviceid].status, 10);
return {
target: itservice.name + ' ' + slaProperty.name,
datapoints: [
[targetStatus, targetSLA[0].to * 1000]
]
target: itservice.name + ' ' + slaProperty,
datapoints: [[targetStatus, targetSLA[0].to * 1000]],
};
} else {
let i;
const slaArr = [];
for (i = 0; i < targetSLA.length; i++) {
if (i === 0) {
slaArr.push([targetSLA[i][slaProperty.property], targetSLA[i].from * 1000]);
slaArr.push([targetSLA[i][slaProperty], targetSLA[i].from * 1000]);
}
slaArr.push([targetSLA[i][slaProperty.property], targetSLA[i].to * 1000]);
slaArr.push([targetSLA[i][slaProperty], targetSLA[i].to * 1000]);
}
return {
target: itservice.name + ' ' + slaProperty.name,
datapoints: slaArr
target: itservice.name + ' ' + slaProperty,
datapoints: slaArr,
};
}
}
@@ -499,13 +497,11 @@ function handleTriggersResponse(triggers, groups, timeRange) {
try {
triggersCount = Number(triggers);
} catch (err) {
console.log("Error when handling triggers count: ", err);
console.log('Error when handling triggers count: ', err);
}
return {
target: "triggers count",
datapoints: [
[triggersCount, timeRange[1] * 1000]
]
target: 'triggers count',
datapoints: [[triggersCount, timeRange[1] * 1000]],
};
} else {
const stats = getTriggerStats(triggers);
@@ -517,7 +513,10 @@ function handleTriggersResponse(triggers, groups, timeRange) {
});
_.each(stats, (severity_stats, group) => {
if (_.includes(groupNames, group)) {
let row = _.map(_.orderBy(_.toPairs(severity_stats), (s) => s[0], ['desc']), (s) => s[1]);
let row = _.map(
_.orderBy(_.toPairs(severity_stats), (s) => s[0], ['desc']),
(s) => s[1]
);
row = _.concat([group], ...row);
table.rows.push(row);
}
@@ -543,38 +542,32 @@ function getTriggerStats(triggers) {
function convertHistoryPoint(point) {
// Value must be a number for properly work
return [
Number(point.value),
point.clock * 1000 + Math.round(point.ns / 1000000)
];
return [Number(point.value), point.clock * 1000 + Math.round(point.ns / 1000000)];
}
function convertTrendPoint(valueType, point) {
let value;
switch (valueType) {
case "min":
case 'min':
value = point.value_min;
break;
case "max":
case 'max':
value = point.value_max;
break;
case "avg":
case 'avg':
value = point.value_avg;
break;
case "sum":
case 'sum':
value = point.value_avg * point.num;
break;
case "count":
case 'count':
value = point.num;
break;
default:
value = point.value_avg;
}
return [
Number(value),
point.clock * 1000
];
return [Number(value), point.clock * 1000];
}
export default {

View File

@@ -13,6 +13,10 @@ jest.mock('@grafana/runtime', () => ({
},
}), { virtual: true });
jest.mock('../components/AnnotationQueryEditor', () => ({
AnnotationQueryEditor: () => {},
}));
describe('ZabbixDatasource', () => {
let ctx = {};

View File

@@ -1,4 +1,4 @@
import { DataQuery, DataSourceJsonData, DataSourceRef, SelectableValue } from "@grafana/data";
import { DataQuery, DataSourceJsonData, DataSourceRef, SelectableValue } from '@grafana/data';
export interface ZabbixDSOptions extends DataSourceJsonData {
username: string;
@@ -34,24 +34,26 @@ export interface ZabbixConnectionTestQuery {
}
export interface ZabbixMetricsQuery extends DataQuery {
triggers: { minSeverity: string; acknowledged: boolean; count: number; };
queryType: string;
datasourceId: number;
group: { filter: string; name?: string; };
host: { filter: string; name?: string; };
application: { filter: string; name?: string; };
itemTag: { filter: string; name?: string; };
item: { filter: string; name?: string; };
textFilter: string;
mode: number;
itemids: number[];
useCaptureGroups: boolean;
proxy?: { filter: string; };
trigger?: { filter: string; };
datasourceId?: number;
group?: { filter: string; name?: string };
host?: { filter: string; name?: string };
application?: { filter: string; name?: string };
itemTag?: { filter: string; name?: string };
item?: { filter: string; name?: string };
textFilter?: string;
mode?: number;
itemids?: string;
useCaptureGroups?: boolean;
proxy?: { filter: string };
trigger?: { filter: string };
itServiceFilter?: string;
tags?: { filter: string; };
functions: ZabbixMetricFunction[];
options: ZabbixQueryOptions;
slaProperty?: any;
slaInterval?: string;
tags?: { filter: string };
triggers?: { minSeverity: number; acknowledged: number; count: boolean };
functions?: MetricFunc[];
options?: ZabbixQueryOptions;
// Problems
showProblems?: ShowProblemTypes;
// Deprecated
@@ -73,14 +75,44 @@ export interface ZabbixQueryOptions {
limit?: number;
useTimeRange?: boolean;
severities?: number[];
// Annotations
showOkEvents?: boolean;
hideAcknowledged?: boolean;
showHostname?: boolean;
}
export interface ZabbixMetricFunction {
name: string;
params: any;
def: { name: string; params: any; };
export interface MetricFunc {
text: string;
params: Array<string | number>;
def: FuncDef;
added?: boolean;
}
export interface FuncDef {
name: string;
params: ParamDef[];
defaultParams: Array<string | number>;
category?: string;
shortName?: any;
fake?: boolean;
version?: string;
description?: string;
/**
* True if the function was not found on the list of available function descriptions.
*/
unknown?: boolean;
}
export type ParamDef = {
name: string;
type: string;
options?: Array<string | number>;
multiple?: boolean;
optional?: boolean;
version?: string;
};
// The paths of these files have moved around in Grafana and they don't resolve properly
// either. Safer not to bother trying to import them just for type hinting.

View File

@@ -20,7 +20,6 @@ export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\
* @return {string} expanded name, ie "CPU system time"
*/
export function expandItemName(name: string, key: string): string {
// extract params from key:
// "system.cpu.util[,system,avg1]" --> ["", "system", "avg1"]
const key_params_str = key.substring(key.indexOf('[') + 1, key.lastIndexOf(']'));
@@ -34,7 +33,7 @@ export function expandItemName(name: string, key: string): string {
}
export function expandItems(items) {
_.forEach(items, item => {
_.forEach(items, (item) => {
item.item = item.name;
item.name = expandItemName(item.item, item.key_);
return item;
@@ -49,7 +48,7 @@ function splitKeyParams(paramStr) {
const split_symbol = ',';
let param = '';
_.forEach(paramStr, symbol => {
_.forEach(paramStr, (symbol) => {
if (symbol === '"' && in_array) {
param += symbol;
} else if (symbol === '"' && quoted) {
@@ -81,14 +80,14 @@ export function containsMacro(itemName) {
export function replaceMacro(item, macros, isTriggerItem?) {
let itemName = isTriggerItem ? item.url : item.name;
const item_macros = itemName.match(MACRO_PATTERN);
_.forEach(item_macros, macro => {
const host_macros = _.filter(macros, m => {
_.forEach(item_macros, (macro) => {
const host_macros = _.filter(macros, (m) => {
if (m.hostid) {
if (isTriggerItem) {
// Trigger item can have multiple hosts
// Check all trigger host ids against macro host id
let hostIdFound = false;
_.forEach(item.hosts, h => {
_.forEach(item.hosts, (h) => {
if (h.hostid === m.hostid) {
hostIdFound = true;
}
@@ -116,7 +115,7 @@ export function replaceMacro(item, macros, isTriggerItem?) {
}
function escapeMacro(macro) {
macro = macro.replace(/\$/, '\\\$');
macro = macro.replace(/\$/, '\\$');
return macro;
}
@@ -125,7 +124,7 @@ export function parseLegacyVariableQuery(query: string): VariableQuery {
const parts = [];
// Split query. Query structure: group.host.app.item
_.each(splitTemplateQuery(query), part => {
_.each(splitTemplateQuery(query), (part) => {
// Replace wildcard to regex
if (part === '*') {
part = '/.*/';
@@ -176,7 +175,7 @@ export function splitTemplateQuery(query) {
if (isContainsBraces(query)) {
const result = query.match(splitPattern);
split = _.map(result, part => {
split = _.map(result, (part) => {
return _.trim(part, '{}');
});
} else {
@@ -201,7 +200,7 @@ export function isRegex(str) {
export function isTemplateVariable(str, templateVariables) {
const variablePattern = /^\$\w+/;
if (variablePattern.test(str)) {
const variables = _.map(templateVariables, variable => {
const variables = _.map(templateVariables, (variable) => {
return '$' + variable.name;
});
return _.includes(variables, str);
@@ -225,7 +224,7 @@ export function getRangeScopedVars(range) {
export function buildRegex(str) {
const matches = str.match(regexPattern);
const pattern = matches[1];
const flags = matches[2] !== "" ? matches[2] : undefined;
const flags = matches[2] !== '' ? matches[2] : undefined;
return new RegExp(pattern, flags);
}
@@ -260,7 +259,7 @@ export function parseInterval(interval: string): number {
const intervalPattern = /(^[\d]+)(y|M|w|d|h|m|s)/g;
const momentInterval: any[] = intervalPattern.exec(interval);
const duration = moment.duration(Number(momentInterval[1]), momentInterval[2]);
return (duration.valueOf() as number);
return duration.valueOf() as number;
}
export function parseTimeShiftInterval(interval) {
@@ -285,15 +284,30 @@ export function parseTimeShiftInterval(interval) {
*/
export function formatAcknowledges(acknowledges) {
if (acknowledges.length) {
let formatted_acknowledges = '<br><br>Acknowledges:<br><table><tr><td><b>Time</b></td>'
+ '<td><b>User</b></td><td><b>Comments</b></td></tr>';
_.each(_.map(acknowledges, ack => {
let formatted_acknowledges =
'<br><br>Acknowledges:<br><table><tr><td><b>Time</b></td>' + '<td><b>User</b></td><td><b>Comments</b></td></tr>';
_.each(
_.map(acknowledges, (ack) => {
const timestamp = moment.unix(ack.clock);
return '<tr><td><i>' + timestamp.format("DD MMM YYYY HH:mm:ss") + '</i></td><td>' + ack.alias
+ ' (' + ack.name + ' ' + ack.surname + ')' + '</td><td>' + ack.message + '</td></tr>';
}), ack => {
return (
'<tr><td><i>' +
timestamp.format('DD MMM YYYY HH:mm:ss') +
'</i></td><td>' +
ack.alias +
' (' +
ack.name +
' ' +
ack.surname +
')' +
'</td><td>' +
ack.message +
'</td></tr>'
);
}),
(ack) => {
formatted_acknowledges = formatted_acknowledges.concat(ack);
});
}
);
formatted_acknowledges = formatted_acknowledges.concat('</table>');
return formatted_acknowledges;
} else {
@@ -307,7 +321,7 @@ export function convertToZabbixAPIUrl(url) {
if (url.match(zabbixAPIUrlPattern)) {
return url;
} else {
return url.replace(trimSlashPattern, "$1");
return url.replace(trimSlashPattern, '$1');
}
}
@@ -319,11 +333,13 @@ export function callOnce(func, promiseKeeper) {
return function () {
if (!promiseKeeper) {
promiseKeeper = Promise.resolve(
func.apply(this, arguments)
.then(result => {
func
.apply(this, arguments)
.then((result) => {
promiseKeeper = null;
return result;
}).catch(err => {
})
.catch((err) => {
promiseKeeper = null;
throw err;
})
@@ -426,19 +442,19 @@ export function mustArray(result: any): any[] {
const getUnitsMap = () => ({
'%': 'percent',
'b': 'decbits', // bits(SI)
'bps': 'bps', // bits/sec(SI)
'B': 'bytes', // bytes(IEC)
'Bps': 'binBps', // bytes/sec(IEC)
b: 'decbits', // bits(SI)
bps: 'bps', // bits/sec(SI)
B: 'bytes', // bytes(IEC)
Bps: 'binBps', // bytes/sec(IEC)
// 'unixtime': 'dateTimeAsSystem',
'uptime': 'dtdhms',
'qps': 'qps', // requests/sec (rps)
'iops': 'iops', // I/O ops/sec (iops)
'Hz': 'hertz', // Hertz (1/s)
'V': 'volt', // Volt (V)
'C': 'celsius', // Celsius (°C)
'RPM': 'rotrpm', // Revolutions per minute (rpm)
'dBm': 'dBm', // Decibel-milliwatt (dBm)
uptime: 'dtdhms',
qps: 'qps', // requests/sec (rps)
iops: 'iops', // I/O ops/sec (iops)
Hz: 'hertz', // Hertz (1/s)
V: 'volt', // Volt (V)
C: 'celsius', // Celsius (°C)
RPM: 'rotrpm', // Revolutions per minute (rpm)
dBm: 'dBm', // Decibel-milliwatt (dBm)
});
const getKnownGrafanaUnits = () => {
@@ -466,7 +482,7 @@ export function convertZabbixUnit(zabbixUnit: string): string {
export function getValueMapping(item, valueMappings: any[]): ValueMapping[] | null {
const { valuemapid } = item;
const mapping = valueMappings?.find(m => m.valuemapid === valuemapid);
const mapping = valueMappings?.find((m) => m.valuemapid === valuemapid);
if (!mapping) {
return null;
}
@@ -478,12 +494,33 @@ export function getValueMapping(item, valueMappings: any[]): ValueMapping[] | nu
options: {
value: m.value,
text: m.newvalue,
}
},
};
return valueMapping;
});
}
export function isProblemsDataFrame(data: DataFrame): boolean {
return data.fields.length && data.fields[0].type === FieldType.other && data.fields[0].config.custom['type'] === 'problems';
return (
data.fields.length && data.fields[0].type === FieldType.other && data.fields[0].config.custom['type'] === 'problems'
);
}
// Swap n and k elements.
export function swap<T>(list: Array<T>, n: number, k: number): Array<T> {
if (list === null || list.length < 2 || k > list.length - 1 || k < 0 || n > list.length - 1 || n < 0) {
return list;
}
const newList: Array<T> = new Array(list.length);
for (let i = 0; i < list.length; i++) {
if (i === n) {
newList[i] = list[k];
} else if (i === k) {
newList[i] = list[n];
} else {
newList[i] = list[i];
}
}
return newList;
}

View File

@@ -1,7 +1,23 @@
import React, { PureComponent } from 'react';
import { cx, css } from '@emotion/css';
import { ZBX_ACK_ACTION_ADD_MESSAGE, ZBX_ACK_ACTION_ACK, ZBX_ACK_ACTION_CHANGE_SEVERITY, ZBX_ACK_ACTION_CLOSE } from '../../datasource-zabbix/constants';
import { Button, VerticalGroup, Spinner, Modal, Checkbox, RadioButtonGroup, stylesFactory, withTheme, Themeable, TextArea } from '@grafana/ui';
import {
ZBX_ACK_ACTION_ADD_MESSAGE,
ZBX_ACK_ACTION_ACK,
ZBX_ACK_ACTION_CHANGE_SEVERITY,
ZBX_ACK_ACTION_CLOSE,
} from '../../datasource-zabbix/constants';
import {
Button,
VerticalGroup,
Spinner,
Modal,
Checkbox,
RadioButtonGroup,
stylesFactory,
withTheme,
Themeable,
TextArea,
} from '@grafana/ui';
import { FAIcon } from '../../components';
import { GrafanaTheme } from '@grafana/data';
@@ -37,12 +53,12 @@ export interface AckProblemData {
}
const severityOptions = [
{value: 0, label: 'Not classified'},
{value: 1, label: 'Information'},
{value: 2, label: 'Warning'},
{value: 3, label: 'Average'},
{value: 4, label: 'High'},
{value: 5, label: 'Disaster'}
{ value: 0, label: 'Not classified' },
{ value: 1, label: 'Information' },
{ value: 2, label: 'Warning' },
{ value: 3, label: 'Average' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Disaster' },
];
export class AckModalUnthemed extends PureComponent<Props, State> {
@@ -67,7 +83,7 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
this.setState({ value: event.target.value, error: false });
}
};
handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.which === KEYBOARD_ENTER_KEY || event.key === 'Enter') {
@@ -75,32 +91,32 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
} else if (event.which === KEYBOARD_ESCAPE_KEY || event.key === 'Escape') {
this.dismiss();
}
}
};
handleBackdropClick = () => {
this.dismiss();
}
};
onAcknowledgeToggle = () => {
this.setState({ acknowledge: !this.state.acknowledge, error: false });
}
};
onChangeSeverityToggle = () => {
this.setState({ changeSeverity: !this.state.changeSeverity, error: false });
}
};
onCloseProblemToggle = () => {
this.setState({ closeProblem: !this.state.closeProblem, error: false });
}
};
onChangeSelectedSeverity = v => {
onChangeSelectedSeverity = (v) => {
this.setState({ selectedSeverity: v });
};
dismiss = () => {
this.setState({ value: '', error: false, errorMessage: '', ackError: '', loading: false });
this.props.onDismiss();
}
};
submit = () => {
const { acknowledge, changeSeverity, closeProblem } = this.state;
@@ -109,7 +125,7 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
if (!this.state.value && !actionSelected) {
return this.setState({
error: true,
errorMessage: 'Enter message text or select an action'
errorMessage: 'Enter message text or select an action',
});
}
@@ -132,51 +148,54 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
}
ackData.action = action;
this.props.onSubmit(ackData).then(() => {
this.props
.onSubmit(ackData)
.then(() => {
this.dismiss();
}).catch(err => {
})
.catch((err) => {
const errorMessage = err.data?.message || err.data?.error || err.data || err.statusText || '';
this.setState({
ackError: errorMessage,
loading: false,
});
});
}
};
renderActions() {
const { canClose } = this.props;
const actions = [
<Checkbox translate="" key="ack" label="Acknowledge" value={this.state.acknowledge} onChange={this.onAcknowledgeToggle} />,
<Checkbox key="ack" label="Acknowledge" value={this.state.acknowledge} onChange={this.onAcknowledgeToggle} />,
<Checkbox
translate=""
key="change-severity"
label="Change severity"
description=""
value={this.state.changeSeverity}
onChange={this.onChangeSeverityToggle}
/>,
this.state.changeSeverity &&
this.state.changeSeverity && (
<RadioButtonGroup
key="severity"
size="sm"
options={severityOptions}
value={this.state.selectedSeverity}
onChange={this.onChangeSelectedSeverity}
/>,
canClose &&
/>
),
canClose && (
<Checkbox
translate=""
key="close"
label="Close problem"
disabled={!canClose}
value={this.state.closeProblem}
onChange={this.onCloseProblemToggle}
/>,
/>
),
];
// <VerticalGroup /> doesn't handle empty elements properly, so don't return it
return actions.filter(e => e);
return actions.filter((e) => e);
}
render() {
@@ -204,8 +223,8 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
>
<div className={inputGroupClass}>
<label className="gf-form-hint">
<TextArea className={inputClass}
translate=""
<TextArea
className={inputClass}
type="text"
name="message"
placeholder="Message"
@@ -213,30 +232,30 @@ export class AckModalUnthemed extends PureComponent<Props, State> {
autoFocus={true}
value={this.state.value}
onChange={this.handleChange}
onKeyDown={this.handleKeyPress}>
</TextArea>
onKeyDown={this.handleKeyPress}
></TextArea>
<small className={inputHintClass}>Press Enter to submit</small>
{this.state.error &&
<small className={inputErrorClass}>{this.state.errorMessage}</small>
}
{this.state.error && <small className={inputErrorClass}>{this.state.errorMessage}</small>}
</label>
</div>
<div className="gf-form">
<VerticalGroup>
{this.renderActions()}
</VerticalGroup>
<VerticalGroup>{this.renderActions()}</VerticalGroup>
</div>
{this.state.ackError &&
{this.state.ackError && (
<div className="gf-form ack-request-error">
<span className={styles.ackError}>{this.state.ackError}</span>
</div>
}
)}
<div className="gf-form-button-row text-center">
<Button variant="primary" onClick={this.submit}>Update</Button>
<Button variant="secondary" onClick={this.dismiss}>Cancel</Button>
<Button variant="primary" onClick={this.submit}>
Update
</Button>
<Button variant="secondary" onClick={this.dismiss}>
Cancel
</Button>
</div>
</Modal>
);

8570
yarn.lock

File diff suppressed because it is too large Load Diff