Merge branch 'master' into backend

This commit is contained in:
Alexander Zobnin
2020-05-28 12:02:36 +03:00
100 changed files with 4537 additions and 3689 deletions

View File

@@ -0,0 +1,277 @@
import React, { PureComponent } from 'react';
import { cx, css } from 'emotion';
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, Input, Forms, stylesFactory, withTheme, Themeable } from '@grafana/ui';
import { FAIcon } from '../../components';
import * as grafanaUi from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
const Checkbox: any = Forms?.Checkbox || (grafanaUi as any).Checkbox;
const RadioButtonGroup: any = Forms?.RadioButtonGroup || (grafanaUi as any).RadioButtonGroup;
const KEYBOARD_ENTER_KEY = 13;
const KEYBOARD_ESCAPE_KEY = 27;
interface Props extends Themeable {
canAck?: boolean;
canClose?: boolean;
severity?: number;
withBackdrop?: boolean;
onSubmit: (data?: AckProblemData) => Promise<any> | any;
onDismiss?: () => void;
}
interface State {
value: string;
error: boolean;
errorMessage: string;
ackError: string;
acknowledge: boolean;
closeProblem: boolean;
changeSeverity: boolean;
selectedSeverity: number;
loading: boolean;
}
export interface AckProblemData {
message: string;
closeProblem?: boolean;
action?: number;
severity?: number;
}
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'}
];
export class AckModalUnthemed extends PureComponent<Props, State> {
static defaultProps: Partial<Props> = {
withBackdrop: true,
};
constructor(props) {
super(props);
this.state = {
value: '',
error: false,
errorMessage: '',
ackError: '',
acknowledge: false,
closeProblem: false,
changeSeverity: false,
selectedSeverity: props.severity || 0,
loading: false,
};
}
handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ value: event.target.value, error: false });
}
handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.which === KEYBOARD_ENTER_KEY || event.key === 'Enter') {
this.submit();
} 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 => {
this.setState({ selectedSeverity: v });
};
dismiss = () => {
this.setState({ value: '', error: false, errorMessage: '', ackError: '', loading: false });
this.props.onDismiss();
}
submit = () => {
const { acknowledge, changeSeverity, closeProblem } = this.state;
const actionSelected = acknowledge || changeSeverity || closeProblem;
if (!this.state.value && !actionSelected) {
return this.setState({
error: true,
errorMessage: 'Enter message text or select an action'
});
}
this.setState({ ackError: '', loading: true });
const ackData: AckProblemData = {
message: this.state.value,
};
let action = ZBX_ACK_ACTION_ADD_MESSAGE;
if (this.state.acknowledge) {
action += ZBX_ACK_ACTION_ACK;
}
if (this.state.changeSeverity) {
action += ZBX_ACK_ACTION_CHANGE_SEVERITY;
ackData.severity = this.state.selectedSeverity;
}
if (this.state.closeProblem) {
action += ZBX_ACK_ACTION_CLOSE;
}
ackData.action = action;
this.props.onSubmit(ackData).then(() => {
this.dismiss();
}).catch(err => {
this.setState({
ackError: err.message || err.data,
loading: false,
});
});
}
renderActions() {
const { canClose } = this.props;
const actions = [
<Checkbox key="ack" label="Acknowledge" value={this.state.acknowledge} onChange={this.onAcknowledgeToggle} />,
<Checkbox
key="change-severity"
label="Change severity"
description=""
value={this.state.changeSeverity}
onChange={this.onChangeSeverityToggle}
/>,
this.state.changeSeverity &&
<RadioButtonGroup
key="severity"
size="sm"
options={severityOptions}
value={this.state.selectedSeverity}
onChange={this.onChangeSelectedSeverity}
/>,
canClose &&
<Checkbox 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);
}
render() {
const { theme } = this.props;
const styles = getStyles(theme);
const modalClass = cx(styles.modal);
const modalTitleClass = cx(styles.modalHeaderTitle);
const inputGroupClass = cx('gf-form', styles.inputGroup);
const inputClass = cx(this.state.error && styles.input);
const inputHintClass = cx('gf-form-hint-text', styles.inputHint);
const inputErrorClass = cx('gf-form-hint-text', styles.inputError);
return (
<Modal
isOpen={true}
onDismiss={this.dismiss}
className={modalClass}
title={
<div className={modalTitleClass}>
{this.state.loading ? <Spinner size={18} /> : <FAIcon icon="reply-all" />}
<span className="p-l-1">Acknowledge Problem</span>
</div>
}
>
<div className={inputGroupClass}>
<label className="gf-form-hint">
<Input className={inputClass}
type="text"
name="message"
placeholder="Message"
maxLength={64}
autoComplete="off"
autoFocus={true}
value={this.state.value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}>
</Input>
<small className={inputHintClass}>Press Enter to submit</small>
{this.state.error &&
<small className={inputErrorClass}>{this.state.errorMessage}</small>
}
</label>
</div>
<div className="gf-form">
<VerticalGroup>
{this.renderActions()}
</VerticalGroup>
</div>
{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>
</div>
</Modal>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const red = theme.colors.red || (theme as any).palette.red;
return {
modal: css`
width: 500px;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
display: flex;
`,
inputGroup: css`
margin-bottom: 16px;
`,
input: css`
border-color: ${red};
border-radius: 2px;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${theme.colors.pageBg || (theme as any).colors.bg1}, 0 0 0px 4px ${red};
`,
inputHint: css`
display: inherit;
float: right;
color: ${theme.colors.textWeak};
`,
inputError: css`
float: left;
color: ${red};
`,
ackError: css`
color: ${red};
`,
};
});
export const AckModal = withTheme(AckModalUnthemed);

View File

@@ -3,29 +3,22 @@ import classNames from 'classnames';
import _ from 'lodash';
import moment from 'moment';
import { isNewProblem, formatLastChange } from '../../utils';
import { ProblemsPanelOptions, ZBXTrigger, ZBXTag } from '../../types';
import { AckProblemData, Modal } from '.././Modal';
import { ProblemsPanelOptions, TriggerSeverity } from '../../types';
import { AckProblemData, AckModal } from '../AckModal';
import EventTag from '../EventTag';
import Tooltip from '.././Tooltip/Tooltip';
import AlertAcknowledges from './AlertAcknowledges';
import AlertIcon from './AlertIcon';
import { ProblemDTO, ZBXTag } from '../../../datasource-zabbix/types';
import { ModalController, Tooltip } from '../../../components';
interface AlertCardProps {
problem: ZBXTrigger;
problem: ProblemDTO;
panelOptions: ProblemsPanelOptions;
onTagClick?: (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => void;
onProblemAck?: (problem: ZBXTrigger, data: AckProblemData) => Promise<any> | any;
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => Promise<any> | any;
}
interface AlertCardState {
showAckDialog: boolean;
}
export default class AlertCard extends PureComponent<AlertCardProps, AlertCardState> {
constructor(props) {
super(props);
this.state = { showAckDialog: false };
}
export default class AlertCard extends PureComponent<AlertCardProps> {
handleTagClick = (tag: ZBXTag, ctrlKey?: boolean, shiftKey?: boolean) => {
if (this.props.onTagClick) {
@@ -35,23 +28,7 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
ackProblem = (data: AckProblemData) => {
const problem = this.props.problem;
return this.props.onProblemAck(problem, data).then(result => {
this.closeAckDialog();
}).catch(err => {
console.log(err);
this.closeAckDialog();
});
}
showAckDialog = () => {
const problem = this.props.problem;
if (problem.showAckButton) {
this.setState({ showAckDialog: true });
}
}
closeAckDialog = () => {
this.setState({ showAckDialog: false });
return this.props.onProblemAck(problem, data);
}
render() {
@@ -59,9 +36,16 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
const showDatasourceName = panelOptions.targets && panelOptions.targets.length > 1;
const cardClass = classNames('alert-rule-item', 'zbx-trigger-card', { 'zbx-trigger-highlighted': panelOptions.highlightBackground });
const descriptionClass = classNames('alert-rule-item__text', { 'zbx-description--newline': panelOptions.descriptionAtNewLine });
const severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === Number(problem.priority));
const lastchange = formatLastChange(problem.lastchangeUnix, panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat);
const age = moment.unix(problem.lastchangeUnix).fromNow(true);
const problemSeverity = Number(problem.severity);
let severityDesc: TriggerSeverity;
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
if (problem.severity) {
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
}
const lastchange = formatLastChange(problem.timestamp, panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat);
const age = moment.unix(problem.timestamp).fromNow(true);
let newProblem = false;
if (panelOptions.highlightNewerThan) {
@@ -72,7 +56,7 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
let problemColor: string;
if (problem.value === '0') {
problemColor = panelOptions.okEventColor;
} else if (panelOptions.markAckEvents && problem.lastEvent.acknowledged === "1") {
} else if (panelOptions.markAckEvents && problem.acknowledged === "1") {
problemColor = panelOptions.ackEventColor;
} else {
problemColor = severityDesc.color;
@@ -153,22 +137,32 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
<span><i className="fa fa-question-circle"></i></span>
</Tooltip>
)}
{problem.lastEvent && (
<AlertAcknowledgesButton problem={problem} onClick={this.showAckDialog} />
{problem.eventid && (
<ModalController>
{({ showModal, hideModal }) => (
<AlertAcknowledgesButton
problem={problem}
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
)}
</div>
</div>
<Modal withBackdrop={true}
isOpen={this.state.showAckDialog}
onSubmit={this.ackProblem}
onClose={this.closeAckDialog} />
</li>
);
}
}
interface AlertHostProps {
problem: ZBXTrigger;
problem: ProblemDTO;
panelOptions: ProblemsPanelOptions;
}
@@ -194,7 +188,7 @@ function AlertHost(props: AlertHostProps) {
}
interface AlertGroupProps {
problem: ZBXTrigger;
problem: ProblemDTO;
panelOptions: ProblemsPanelOptions;
}
@@ -247,7 +241,7 @@ function AlertSeverity(props) {
}
interface AlertAcknowledgesButtonProps {
problem: ZBXTrigger;
problem: ProblemDTO;
onClick: (event?) => void;
}

View File

@@ -1,33 +1,34 @@
import React, { CSSProperties } from 'react';
import classNames from 'classnames';
import { ZBXTrigger } from '../../types';
import React, { FC } from 'react';
import { cx, css } from 'emotion';
import { GFHeartIcon } from '../../../components';
import { ProblemDTO } from '../../../datasource-zabbix/types';
interface AlertIconProps {
problem: ZBXTrigger;
interface Props {
problem: ProblemDTO;
color: string;
blink?: boolean;
highlightBackground?: boolean;
}
export default function AlertIcon(props: AlertIconProps) {
const { problem, color, blink, highlightBackground } = props;
const priority = Number(problem.priority);
let iconClass = '';
if (problem.value === '1' && priority >= 2) {
iconClass = 'icon-gf-critical';
} else {
iconClass = 'icon-gf-online';
}
export const AlertIcon: FC<Props> = ({ problem, color, blink, highlightBackground }) => {
const severity = Number(problem.severity);
const status = problem.value === '1' && severity >= 2 ? 'critical' : 'online';
const className = classNames('icon-gf', iconClass, { 'zabbix-trigger--blinked': blink });
const style: CSSProperties = {};
if (!highlightBackground) {
style.color = color;
}
const iconClass = cx(
'icon-gf',
blink && 'zabbix-trigger--blinked',
);
const wrapperClass = cx(
'alert-rule-item__icon',
!highlightBackground && css`color: ${color}`
);
return (
<div className="alert-rule-item__icon" style={style}>
<i className={className}></i>
<div className={wrapperClass}>
<GFHeartIcon status={status} className={iconClass} />
</div>
);
}
};
export default AlertIcon;

View File

@@ -1,23 +1,24 @@
import React, { PureComponent, CSSProperties } from 'react';
import classNames from 'classnames';
import { ProblemsPanelOptions, ZBXTrigger, GFTimeRange, ZBXTag } from '../../types';
import { AckProblemData } from '.././Modal';
import { ProblemsPanelOptions, GFTimeRange } from '../../types';
import { AckProblemData } from '../AckModal';
import AlertCard from './AlertCard';
import { ProblemDTO, ZBXTag } from '../../../datasource-zabbix/types';
export interface AlertListProps {
problems: ZBXTrigger[];
problems: ProblemDTO[];
panelOptions: ProblemsPanelOptions;
loading?: boolean;
timeRange?: GFTimeRange;
pageSize?: number;
fontSize?: number;
onProblemAck?: (problem: ZBXTrigger, data: AckProblemData) => void;
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => void;
onTagClick?: (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => void;
}
interface AlertListState {
page: number;
currentProblems: ZBXTrigger[];
currentProblems: ProblemDTO[];
}
export default class AlertList extends PureComponent<AlertListProps, AlertListState> {
@@ -51,7 +52,7 @@ export default class AlertList extends PureComponent<AlertListProps, AlertListSt
}
}
handleProblemAck = (problem: ZBXTrigger, data: AckProblemData) => {
handleProblemAck = (problem: ProblemDTO, data: AckProblemData) => {
return this.props.onProblemAck(problem, data);
}
@@ -68,7 +69,7 @@ export default class AlertList extends PureComponent<AlertListProps, AlertListSt
<ol className={alertListClass}>
{currentProblems.map(problem =>
<AlertCard
key={`${problem.triggerid}-${problem.datasource}`}
key={`${problem.triggerid}-${problem.eventid}-${problem.datasource}`}
problem={problem}
panelOptions={panelOptions}
onTagClick={this.handleTagClick}

View File

@@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import { ZBXTag } from '../types';
import Tooltip from './Tooltip/Tooltip';
import Tooltip from '../../components/Tooltip/Tooltip';
const TAG_COLORS = [
'#E24D42',

View File

@@ -1,14 +0,0 @@
import React from 'react';
interface FAIconProps {
icon: string;
customClass?: string;
}
export default function FAIcon(props: FAIconProps) {
return (
<span className={`fa-icon-container ${props.customClass || ''}`}>
<i className={`fa fa-${props.icon}`}></i>
</span>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
import classNames from 'classnames';
interface GFHeartIconProps {
status: 'critical' | 'warning' | 'online' | 'ok' | 'problem';
className?: string;
}
export default function GFHeartIcon(props: GFHeartIconProps) {
const status = props.status;
const className = classNames("icon-gf", props.className,
{ "icon-gf-critical": status === 'critical' || status === 'problem' || status === 'warning'},
{ "icon-gf-online": status === 'online' || status === 'ok' },
);
return (
<i className={className}></i>
);
}

View File

@@ -1,134 +0,0 @@
import React, { PureComponent } from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
const KEYBOARD_ENTER_KEY = 13;
const KEYBOARD_ESCAPE_KEY = 27;
interface ModalProps {
isOpen?: boolean;
withBackdrop?: boolean;
onSubmit: (data?: AckProblemData) => Promise<any> | any;
onClose?: () => void;
}
interface ModalState {
value: string;
error: boolean;
message: string;
}
export interface AckProblemData {
message: string;
closeProblem?: boolean;
action?: number;
}
export class Modal extends PureComponent<ModalProps, ModalState> {
modalContainer: HTMLElement;
constructor(props) {
super(props);
this.state = {
value: '',
error: false,
message: '',
};
this.modalContainer = document.body;
}
handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ value: event.target.value, error: false });
}
handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.which === KEYBOARD_ENTER_KEY || event.key === 'Enter') {
this.submit();
} else if (event.which === KEYBOARD_ESCAPE_KEY || event.key === 'Escape') {
this.dismiss();
}
}
handleBackdropClick = () => {
this.dismiss();
}
dismiss = () => {
this.setState({ value: '', error: false, message: '' });
this.props.onClose();
}
submit = () => {
if (!this.state.value) {
return this.setState({
error: true,
message: 'Enter message text'
});
}
this.props.onSubmit({
message: this.state.value
}).then(() => {
this.dismiss();
});
}
render() {
if (!this.props.isOpen || !this.modalContainer) {
return null;
}
const inputClass = classNames('gf-form-input', { 'zbx-ack-error': this.state.error });
const modalNode = (
<div className="modal modal--narrow zbx-ack-modal" key="modal">
<div className="modal-body">
<div className="modal-header">
<h2 className="modal-header-title">
<i className="fa fa-reply-all"></i>
<span className="p-l-1">Acknowledge Problem</span>
</h2>
<a className="modal-header-close" onClick={this.dismiss}>
<i className="fa fa-remove"></i>
</a>
</div>
<div className="modal-content">
<div className="gf-form">
<label className="gf-form-hint">
<input className={inputClass}
type="text"
name="message"
placeholder="Message"
maxLength={64}
autoComplete="off"
autoFocus={true}
value={this.state.value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}>
</input>
<small className="gf-form-hint-text muted">Press Enter to submit</small>
{this.state.error &&
<small className="gf-form-hint-text muted ack-error-message">{this.state.message}</small>
}
</label>
</div>
<div className="gf-form-button-row text-center">
<button className="btn btn-success" onClick={this.submit}>Acknowledge</button>
<button className="btn btn-inverse" onClick={this.dismiss}>Cancel</button>
</div>
</div>
</div>
</div>
);
const modalNodeWithBackdrop = [
modalNode,
<div className="modal-backdrop in" key="modal-backdrop" onClick={this.handleBackdropClick}></div>
];
const modal = this.props.withBackdrop ? modalNodeWithBackdrop : modalNode;
return ReactDOM.createPortal(modal, this.modalContainer);
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { css } from 'emotion';
import { RTCell } from '../../types';
import { ProblemDTO } from '../../../datasource-zabbix/types';
import { FAIcon } from '../../../components';
import { useTheme, stylesFactory } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
countLabel: css`
font-size: ${theme.typography.size.sm};
`,
};
});
export const AckCell: React.FC<RTCell<ProblemDTO>> = (props: RTCell<ProblemDTO>) => {
const problem = props.original;
const theme = useTheme();
const styles = getStyles(theme);
return (
<div>
{problem.acknowledges?.length > 0 &&
<>
<FAIcon icon="comments" />
<span className={styles.countLabel}> ({problem.acknowledges?.length})</span>
</>
}
</div>
);
};
export default AckCell;

View File

@@ -1,22 +1,23 @@
import React, { PureComponent } from 'react';
import moment from 'moment';
import * as utils from '../../../datasource-zabbix/utils';
import { ZBXTrigger, ZBXItem, ZBXAcknowledge, ZBXHost, ZBXGroup, ZBXEvent, GFTimeRange, RTRow, ZBXTag, ZBXAlert } from '../../types';
import { Modal, AckProblemData } from '../Modal';
import { ProblemDTO, ZBXHost, ZBXGroup, ZBXEvent, ZBXTag, ZBXAlert } from '../../../datasource-zabbix/types';
import { ZBXItem, GFTimeRange, RTRow } from '../../types';
import { AckModal, AckProblemData } from '../AckModal';
import EventTag from '../EventTag';
import Tooltip from '../Tooltip/Tooltip';
import ProblemStatusBar from './ProblemStatusBar';
import AcknowledgesList from './AcknowledgesList';
import ProblemTimeline from './ProblemTimeline';
import FAIcon from '../FAIcon';
import { FAIcon, ExploreButton, AckButton, Tooltip, ModalController } from '../../../components';
interface ProblemDetailsProps extends RTRow<ZBXTrigger> {
interface ProblemDetailsProps extends RTRow<ProblemDTO> {
rootWidth: number;
timeRange: GFTimeRange;
showTimeline?: boolean;
getProblemEvents: (problem: ZBXTrigger) => Promise<ZBXEvent[]>;
getProblemAlerts: (problem: ZBXTrigger) => Promise<ZBXAlert[]>;
onProblemAck?: (problem: ZBXTrigger, data: AckProblemData) => Promise<any> | any;
panelId?: number;
getProblemEvents: (problem: ProblemDTO) => Promise<ZBXEvent[]>;
getProblemAlerts: (problem: ProblemDTO) => Promise<ZBXAlert[]>;
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => Promise<any> | any;
onTagClick?: (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => void;
}
@@ -24,17 +25,15 @@ interface ProblemDetailsState {
events: ZBXEvent[];
alerts: ZBXAlert[];
show: boolean;
showAckDialog: boolean;
}
export default class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDetailsState> {
export class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDetailsState> {
constructor(props) {
super(props);
this.state = {
events: [],
alerts: [],
show: false,
showAckDialog: false,
};
}
@@ -71,32 +70,20 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
}
ackProblem = (data: AckProblemData) => {
const problem = this.props.original as ZBXTrigger;
return this.props.onProblemAck(problem, data).then(result => {
this.closeAckDialog();
}).catch(err => {
console.log(err);
this.closeAckDialog();
});
}
showAckDialog = () => {
this.setState({ showAckDialog: true });
}
closeAckDialog = () => {
this.setState({ showAckDialog: false });
const problem = this.props.original as ProblemDTO;
return this.props.onProblemAck(problem, data);
}
render() {
const problem = this.props.original as ZBXTrigger;
const problem = this.props.original as ProblemDTO;
const alerts = this.state.alerts;
const rootWidth = this.props.rootWidth;
const displayClass = this.state.show ? 'show' : '';
const wideLayout = rootWidth > 1200;
const compactStatusBar = rootWidth < 800 || problem.acknowledges && wideLayout && rootWidth < 1400;
const age = moment.unix(problem.lastchangeUnix).fromNow(true);
const age = moment.unix(problem.timestamp).fromNow(true);
const showAcknowledges = problem.acknowledges && problem.acknowledges.length !== 0;
const problemSeverity = Number(problem.severity);
return (
<div className={`problem-details-container ${displayClass}`}>
@@ -109,20 +96,36 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
</div>
{problem.items && <ProblemItems items={problem.items} />}
</div>
<div className="problem-actions-left">
<ExploreButton problem={problem} panelId={this.props.panelId} />
</div>
<ProblemStatusBar problem={problem} alerts={alerts} className={compactStatusBar && 'compact'} />
{problem.showAckButton &&
<div className="problem-actions">
<ProblemActionButton className="navbar-button navbar-button--settings"
icon="reply-all"
tooltip="Acknowledge problem"
onClick={this.showAckDialog} />
<ModalController>
{({ showModal, hideModal }) => (
<AckButton
className="navbar-button navbar-button--settings"
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
</div>
}
</div>
{problem.comments &&
<div className="problem-description">
<span className="description-label">Description:&nbsp;</span>
<span>{problem.comments}</span>
<div className="problem-description-row">
<div className="problem-description">
<span className="description-label">Description:&nbsp;</span>
<span>{problem.comments}</span>
</div>
</div>
}
{problem.tags && problem.tags.length > 0 &&
@@ -169,10 +172,6 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
{problem.groups && <ProblemGroups groups={problem.groups} className="problem-details-right-item" />}
{problem.hosts && <ProblemHosts hosts={problem.hosts} className="problem-details-right-item" />}
</div>
<Modal withBackdrop={true}
isOpen={this.state.showAckDialog}
onSubmit={this.ackProblem}
onClose={this.closeAckDialog} />
</div>
);
}
@@ -242,33 +241,3 @@ class ProblemHosts extends PureComponent<ProblemHostsProps> {
));
}
}
interface ProblemActionButtonProps {
icon: string;
tooltip?: string;
className?: string;
onClick?: (event?) => void;
}
class ProblemActionButton extends PureComponent<ProblemActionButtonProps> {
handleClick = (event) => {
this.props.onClick(event);
}
render() {
const { icon, tooltip, className } = this.props;
let button = (
<button className={`btn problem-action-button ${className || ''}`} onClick={this.handleClick}>
<FAIcon icon={icon} />
</button>
);
if (tooltip) {
button = (
<Tooltip placement="bottom" content={tooltip}>
{button}
</Tooltip>
);
}
return button;
}
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import FAIcon from '../FAIcon';
import Tooltip from '../Tooltip/Tooltip';
import FAIcon from '../../../components/FAIcon/FAIcon';
import Tooltip from '../../../components/Tooltip/Tooltip';
import { ZBXTrigger, ZBXAlert } from '../../types';
export interface ProblemStatusBarProps {

View File

@@ -371,7 +371,7 @@ class TimelineRegions extends PureComponent<TimelineRegionsProps> {
};
return (
<rect key={event.eventid} className={className} {...attributes} />
<rect key={`${event.eventid}-${index}`} className={className} {...attributes} />
);
});
@@ -480,7 +480,7 @@ class TimelinePoints extends PureComponent<TimelinePointsProps, TimelinePointsSt
return (
<TimelinePoint
key={event.eventid}
key={`${event.eventid}-${i}`}
className={className}
x={posLeft}
r={pointR}
@@ -611,7 +611,7 @@ class TimelineAcks extends PureComponent<TimelineAcksProps, TimelineAcksState> {
return (
<TimelineAck
key={ack.eventid}
key={`${ack.eventid}-${i}`}
x={posLeft}
r={pointR}
highlighted={highlighted}

View File

@@ -1,26 +1,28 @@
import React, { PureComponent } from 'react';
import ReactTable from 'react-table';
import ReactTable from 'react-table-6';
import classNames from 'classnames';
import _ from 'lodash';
import moment from 'moment';
import * as utils from '../../../datasource-zabbix/utils';
import { isNewProblem } from '../../utils';
import { ProblemsPanelOptions, ZBXTrigger, ZBXEvent, GFTimeRange, RTCell, ZBXTag, TriggerSeverity, RTResized, ZBXAlert } from '../../types';
import EventTag from '../EventTag';
import ProblemDetails from './ProblemDetails';
import { AckProblemData } from '../Modal';
import GFHeartIcon from '../GFHeartIcon';
import { ProblemDetails } from './ProblemDetails';
import { AckProblemData } from '../AckModal';
import { GFHeartIcon, FAIcon } from '../../../components';
import { ProblemsPanelOptions, GFTimeRange, RTCell, TriggerSeverity, RTResized } from '../../types';
import { ProblemDTO, ZBXEvent, ZBXTag, ZBXAlert } from '../../../datasource-zabbix/types';
import { AckCell } from './AckCell';
export interface ProblemListProps {
problems: ZBXTrigger[];
problems: ProblemDTO[];
panelOptions: ProblemsPanelOptions;
loading?: boolean;
timeRange?: GFTimeRange;
pageSize?: number;
fontSize?: number;
getProblemEvents: (problem: ZBXTrigger) => Promise<ZBXEvent[]>;
getProblemAlerts: (problem: ZBXTrigger) => Promise<ZBXAlert[]>;
onProblemAck?: (problem: ZBXTrigger, data: AckProblemData) => void;
panelId?: number;
getProblemEvents: (problem: ProblemDTO) => Promise<ZBXEvent[]>;
getProblemAlerts: (problem: ProblemDTO) => Promise<ZBXAlert[]>;
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => void;
onTagClick?: (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => void;
onPageSizeChange?: (pageSize: number, pageIndex: number) => void;
onColumnResize?: (newResized: RTResized) => void;
@@ -47,7 +49,7 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
this.rootRef = ref;
}
handleProblemAck = (problem: ZBXTrigger, data: AckProblemData) => {
handleProblemAck = (problem: ProblemDTO, data: AckProblemData) => {
return this.props.onProblemAck(problem, data);
}
@@ -85,19 +87,21 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
const result = [];
const options = this.props.panelOptions;
const highlightNewerThan = options.highlightNewEvents && options.highlightNewerThan;
const statusCell = props => StatusCell(props, options.okEventColor, DEFAULT_PROBLEM_COLOR, highlightNewerThan);
const statusCell = props => StatusCell(props, highlightNewerThan);
const statusIconCell = props => StatusIconCell(props, highlightNewerThan);
const hostNameCell = props => <HostCell name={props.original.host} maintenance={props.original.maintenance} />;
const hostTechNameCell = props => <HostCell name={props.original.hostTechName} maintenance={props.original.maintenance} />;
const columns = [
{ Header: 'Host', accessor: 'host', show: options.hostField },
{ Header: 'Host (Technical Name)', accessor: 'hostTechName', show: options.hostTechNameField },
{ Header: 'Host', id: 'host', show: options.hostField, Cell: hostNameCell },
{ Header: 'Host (Technical Name)', id: 'hostTechName', show: options.hostTechNameField, Cell: hostTechNameCell },
{ Header: 'Host Groups', accessor: 'groups', show: options.hostGroups, Cell: GroupCell },
{ Header: 'Proxy', accessor: 'proxy', show: options.hostProxy },
{
Header: 'Severity', show: options.severityField, className: 'problem-severity', width: 120,
accessor: problem => problem.priority,
id: 'severity',
Cell: props => SeverityCell(props, options.triggerSeverity, options.markAckEvents, options.ackEventColor),
Cell: props => SeverityCell(props, options.triggerSeverity, options.markAckEvents, options.ackEventColor, options.okEventColor),
},
{
Header: '', id: 'statusIcon', show: options.statusIcon, className: 'problem-status-icon', width: 50,
@@ -106,17 +110,21 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
},
{ Header: 'Status', accessor: 'value', show: options.statusField, width: 100, Cell: statusCell },
{ Header: 'Problem', accessor: 'description', minWidth: 200, Cell: ProblemCell},
{
Header: 'Ack', id: 'ack', show: options.ackField, width: 70,
Cell: props => <AckCell {...props} />
},
{
Header: 'Tags', accessor: 'tags', show: options.showTags, className: 'problem-tags',
Cell: props => <TagCell {...props} onTagClick={this.handleTagClick} />
},
{
Header: 'Age', className: 'problem-age', width: 100, show: options.ageField, accessor: 'lastchangeUnix',
Header: 'Age', className: 'problem-age', width: 100, show: options.ageField, accessor: 'timestamp',
id: 'age',
Cell: AgeCell,
},
{
Header: 'Time', className: 'last-change', width: 150, accessor: 'lastchangeUnix',
Header: 'Time', className: 'last-change', width: 150, accessor: 'timestamp',
id: 'lastchange',
Cell: props => LastChangeCell(props, options.customLastChangeFormat && options.lastChangeFormat),
},
@@ -159,10 +167,12 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
rootWidth={this.rootWidth}
timeRange={this.props.timeRange}
showTimeline={panelOptions.problemTimeline}
panelId={this.props.panelId}
getProblemEvents={this.props.getProblemEvents}
getProblemAlerts={this.props.getProblemAlerts}
onProblemAck={this.handleProblemAck}
onTagClick={this.handleTagClick}
subRows={false}
/>
}
expanded={this.getExpandedPage(this.state.page)}
@@ -176,14 +186,41 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
}
}
function SeverityCell(props: RTCell<ZBXTrigger>, problemSeverityDesc: TriggerSeverity[], markAckEvents?: boolean, ackEventColor?: string) {
interface HostCellProps {
name: string;
maintenance: boolean;
}
const HostCell: React.FC<HostCellProps> = ({ name, maintenance }) => {
return (
<div>
<span style={{ paddingRight: '0.4rem' }}>{name}</span>
{maintenance && <FAIcon customClass="fired" icon="wrench" />}
</div>
);
};
function SeverityCell(
props: RTCell<ProblemDTO>,
problemSeverityDesc: TriggerSeverity[],
markAckEvents?: boolean,
ackEventColor?: string,
okColor = DEFAULT_OK_COLOR
) {
const problem = props.original;
let color: string;
const severityDesc = _.find(problemSeverityDesc, s => s.priority === Number(props.original.priority));
color = severityDesc.color;
let severityDesc: TriggerSeverity;
const severity = Number(problem.severity);
severityDesc = _.find(problemSeverityDesc, s => s.priority === severity);
if (problem.severity && problem.value === '1') {
severityDesc = _.find(problemSeverityDesc, s => s.priority === severity);
}
color = problem.value === '0' ? okColor : severityDesc.color;
// Mark acknowledged triggers with different color
if (markAckEvents && problem.lastEvent.acknowledged === "1") {
if (markAckEvents && problem.acknowledged === "1") {
color = ackEventColor;
}
@@ -197,9 +234,9 @@ function SeverityCell(props: RTCell<ZBXTrigger>, problemSeverityDesc: TriggerSev
const DEFAULT_OK_COLOR = 'rgb(56, 189, 113)';
const DEFAULT_PROBLEM_COLOR = 'rgb(215, 0, 0)';
function StatusCell(props: RTCell<ZBXTrigger>, okColor = DEFAULT_OK_COLOR, problemColor = DEFAULT_PROBLEM_COLOR, highlightNewerThan?: string) {
function StatusCell(props: RTCell<ProblemDTO>, highlightNewerThan?: string) {
const status = props.value === '0' ? 'RESOLVED' : 'PROBLEM';
const color = props.value === '0' ? okColor : problemColor;
const color = props.value === '0' ? DEFAULT_OK_COLOR : DEFAULT_PROBLEM_COLOR;
let newProblem = false;
if (highlightNewerThan) {
newProblem = isNewProblem(props.original, highlightNewerThan);
@@ -209,7 +246,7 @@ function StatusCell(props: RTCell<ZBXTrigger>, okColor = DEFAULT_OK_COLOR, probl
);
}
function StatusIconCell(props: RTCell<ZBXTrigger>, highlightNewerThan?: string) {
function StatusIconCell(props: RTCell<ProblemDTO>, highlightNewerThan?: string) {
const status = props.value === '0' ? 'ok' : 'problem';
let newProblem = false;
if (highlightNewerThan) {
@@ -223,7 +260,7 @@ function StatusIconCell(props: RTCell<ZBXTrigger>, highlightNewerThan?: string)
return <GFHeartIcon status={status} className={className} />;
}
function GroupCell(props: RTCell<ZBXTrigger>) {
function GroupCell(props: RTCell<ProblemDTO>) {
let groups = "";
if (props.value && props.value.length) {
groups = props.value.map(g => g.name).join(', ');
@@ -233,7 +270,7 @@ function GroupCell(props: RTCell<ZBXTrigger>) {
);
}
function ProblemCell(props: RTCell<ZBXTrigger>) {
function ProblemCell(props: RTCell<ProblemDTO>) {
const comments = props.original.comments;
return (
<div>
@@ -243,23 +280,23 @@ function ProblemCell(props: RTCell<ZBXTrigger>) {
);
}
function AgeCell(props: RTCell<ZBXTrigger>) {
function AgeCell(props: RTCell<ProblemDTO>) {
const problem = props.original;
const timestamp = moment.unix(problem.lastchangeUnix);
const timestamp = moment.unix(problem.timestamp);
const age = timestamp.fromNow(true);
return <span>{age}</span>;
}
function LastChangeCell(props: RTCell<ZBXTrigger>, customFormat?: string) {
function LastChangeCell(props: RTCell<ProblemDTO>, customFormat?: string) {
const DEFAULT_TIME_FORMAT = "DD MMM YYYY HH:mm:ss";
const problem = props.original;
const timestamp = moment.unix(problem.lastchangeUnix);
const timestamp = moment.unix(problem.timestamp);
const format = customFormat || DEFAULT_TIME_FORMAT;
const lastchange = timestamp.format(format);
return <span>{lastchange}</span>;
}
interface TagCellProps extends RTCell<ZBXTrigger> {
interface TagCellProps extends RTCell<ProblemDTO> {
onTagClick: (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => void;
}

View File

@@ -1,75 +0,0 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import BodyPortal from './Portal';
import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
import Transition from 'react-transition-group/Transition';
const defaultTransitionStyles = {
transition: 'opacity 200ms linear',
opacity: 0,
};
const transitionStyles = {
exited: { opacity: 0 },
entering: { opacity: 0 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
};
interface Props {
renderContent: (content: any) => any;
show: boolean;
placement?: any;
content: string | ((props: any) => JSX.Element);
refClassName?: string;
popperClassName?: string;
}
class Popper extends PureComponent<Props> {
render() {
const { children, renderContent, show, placement, refClassName } = this.props;
const { content } = this.props;
const popperClassName = classNames('popper', this.props.popperClassName);
return (
<Manager>
<Reference>
{({ ref }) => (
<div className={`popper_ref ${refClassName || ''}`} ref={ref}>
{children}
</div>
)}
</Reference>
<Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
{transitionState => (
<BodyPortal>
<ReactPopper placement={placement}>
{({ ref, style, placement, arrowProps }) => {
return (
<div
ref={ref}
style={{
...style,
...defaultTransitionStyles,
...transitionStyles[transitionState],
}}
data-placement={placement}
className={popperClassName}
>
<div className="popper__background">
{renderContent(content)}
<div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
</div>
</div>
);
}}
</ReactPopper>
</BodyPortal>
)}
</Transition>
</Manager>
);
}
}
export default Popper;

View File

@@ -1,35 +0,0 @@
import { PureComponent } from 'react';
import ReactDOM from 'react-dom';
interface Props {
className?: string;
root?: HTMLElement;
}
export default class BodyPortal extends PureComponent<Props> {
node: HTMLElement = document.createElement('div');
portalRoot: HTMLElement;
constructor(props) {
super(props);
const {
className,
root = document.body
} = this.props;
if (className) {
this.node.classList.add(className);
}
this.portalRoot = root;
this.portalRoot.appendChild(this.node);
}
componentWillUnmount() {
this.portalRoot.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}

View File

@@ -1,17 +0,0 @@
import React, { PureComponent } from 'react';
import Popper from './Popper';
import withPopper, { UsingPopperProps } from './withPopper';
class Tooltip extends PureComponent<UsingPopperProps> {
render() {
const { children, hidePopper, showPopper, className, ...restProps } = this.props;
return (
<div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
<Popper {...restProps}>{children}</Popper>
</div>
);
}
}
export default withPopper(Tooltip);

View File

@@ -1,90 +0,0 @@
import React from 'react';
export interface UsingPopperProps {
showPopper: (prevState: object) => void;
hidePopper: (prevState: object) => void;
renderContent: (content: any) => any;
show: boolean;
placement?: string;
content: string | ((props: any) => JSX.Element);
className?: string;
refClassName?: string;
popperClassName?: string;
}
interface Props {
placement?: string;
className?: string;
refClassName?: string;
popperClassName?: string;
content: string | ((props: any) => JSX.Element);
}
interface State {
placement: string;
show: boolean;
}
export default function withPopper(WrappedComponent) {
return class extends React.Component<Props, State> {
constructor(props) {
super(props);
this.setState = this.setState.bind(this);
this.state = {
placement: this.props.placement || 'auto',
show: false,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.placement && nextProps.placement !== this.state.placement) {
this.setState(prevState => {
return {
...prevState,
placement: nextProps.placement,
};
});
}
}
showPopper = () => {
this.setState(prevState => ({
...prevState,
show: true,
}));
};
hidePopper = () => {
this.setState(prevState => ({
...prevState,
show: false,
}));
};
renderContent(content) {
if (typeof content === 'function') {
// If it's a function we assume it's a React component
const ReactComponent = content;
return <ReactComponent />;
}
return content;
}
render() {
const { show, placement } = this.state;
const className = this.props.className || '';
return (
<WrappedComponent
{...this.props}
showPopper={this.showPopper}
hidePopper={this.hidePopper}
renderContent={this.renderContent}
show={show}
placement={placement}
className={className}
/>
);
}
};
}

View File

@@ -1,57 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
const template = `
<value-select-dropdown
variable="ctrl.dsOptions"
on-updated="ctrl.onChange(ctrl.dsOptions)"
dashboard="ctrl.dashboard">
</value-select-dropdown>
`;
angular
.module('grafana.directives')
.directive('datasourceSelector', () => {
return {
scope: {
datasources: "=",
options: "=",
onChange: "&"
},
controller: DatasourceSelectorCtrl,
controllerAs: 'ctrl',
template: template
};
});
class DatasourceSelectorCtrl {
/** @ngInject */
constructor($scope) {
this.scope = $scope;
let datasources = $scope.datasources;
let options = $scope.options;
this.dsOptions = {
multi: true,
current: {value: datasources, text: datasources.join(" + ")},
options: _.map(options, (ds) => {
return {text: ds, value: ds, selected: _.includes(datasources, ds)};
})
};
// Fix for Grafana 6.0
// https://github.com/grafana/grafana/blob/v6.0.0/public/app/core/directives/value_select_dropdown.ts#L291
this.dashboard = {
on: () => {}
};
}
onChange(updatedOptions) {
let newDataSources = updatedOptions.current.value;
this.scope.datasources = newDataSources;
// Run after model was changed
this.scope.$$postDigest(() => {
this.scope.onChange();
});
}
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 336 336" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="Layer_2"><g id="Layer_1-2"><path d="M71.833,181.833c-8.666,-8.666 -27.375,-5.916 -32.25,10.667c-4.875,-15.5 -26.5,-18.958 -34.666,-8c-7.34,9.842 -6.304,23.732 2.416,32.375l29.417,29.458c1.578,1.424 4.005,1.424 5.583,0l29.459,-29.458c9.494,-9.664 9.494,-25.378 0,-35.042l0.041,0Z" style="fill:#0b9d4d;fill-rule:nonzero;"/><path d="M71.833,91.958c-8.333,-8.333 -25.916,-5.958 -31.541,9.042l-2.792,6.958l12.083,2.875c1.471,0.358 2.514,1.686 2.514,3.199c0,0.478 -0.104,0.951 -0.305,1.385l-7.875,16.666c-0.519,1.161 -1.688,1.902 -2.959,1.875c-0.474,0.005 -0.943,-0.095 -1.375,-0.291c-1.507,-0.776 -2.195,-2.586 -1.583,-4.167l6.167,-13l-11.834,-2.833c-0.918,-0.216 -1.696,-0.826 -2.125,-1.667c-0.394,-0.859 -0.394,-1.849 0,-2.708l5.292,-13.5c-8.125,-9.084 -23.792,-9.959 -30.458,-0.959c-7.34,9.842 -6.304,23.732 2.416,32.375l29.417,29.459c1.578,1.424 4.005,1.424 5.583,0l29.459,-29.459c9.606,-9.709 9.568,-25.586 -0.084,-35.25Z" style="fill:#d40000;fill-rule:nonzero;"/><path d="M71.649,270.203c-8.333,-8.334 -25.917,-5.959 -31.542,9.041l-2.791,6.959l12.083,2.875c1.47,0.357 2.514,1.685 2.514,3.198c0,0.478 -0.104,0.951 -0.306,1.385l-7.875,16.667c-0.518,1.16 -1.687,1.901 -2.958,1.875c-0.474,0.004 -0.943,-0.096 -1.375,-0.292c-1.508,-0.776 -2.195,-2.585 -1.583,-4.167l6.166,-13l-11.833,-2.833c-0.918,-0.216 -1.697,-0.826 -2.125,-1.667c-0.394,-0.859 -0.394,-1.849 0,-2.708l5.292,-13.5c-8.125,-9.083 -23.792,-9.958 -30.459,-0.958c-7.34,9.841 -6.303,23.732 2.417,32.375l29.417,29.458c1.578,1.424 4.005,1.424 5.583,0l29.458,-29.458c9.607,-9.71 9.569,-25.587 -0.083,-35.25Z" style="fill:#d40000;fill-rule:nonzero;"/><path d="M71.649,4.685c-8.333,-8.334 -25.917,-5.959 -31.542,9.041l-2.791,6.959l12.083,2.875c1.47,0.357 2.514,1.685 2.514,3.198c0,0.479 -0.104,0.951 -0.306,1.385l-7.875,16.667c-0.518,1.161 -1.687,1.902 -2.958,1.875c-0.474,0.004 -0.943,-0.096 -1.375,-0.292c-1.508,-0.776 -2.195,-2.585 -1.583,-4.167l6.166,-13l-11.833,-2.833c-0.918,-0.216 -1.697,-0.826 -2.125,-1.667c-0.394,-0.859 -0.394,-1.849 0,-2.708l5.292,-13.5c-8.125,-9.083 -23.792,-9.958 -30.459,-0.958c-7.34,9.841 -6.303,23.732 2.417,32.375l29.417,29.458c1.578,1.424 4.005,1.424 5.583,0l29.458,-29.458c9.607,-9.71 9.569,-25.586 -0.083,-35.25Z" style="fill:#d40000;fill-rule:nonzero;"/><rect x="91.667" y="266.595" width="108.333" height="68.447" style="fill:#8e0000;"/><path d="M109.556,318.667l0,-35.834l208.055,0l0,35.834l-208.055,0m-17.889,16.666l239.361,0c2.453,0 4.472,-1.881 4.472,-4.166l0,-60.834c0,-2.285 -2.019,-4.166 -4.472,-4.166l-239.361,0l0,69.166Z" style="fill:#d40000;fill-rule:nonzero;"/><rect x="91.667" y="179.125" width="108.333" height="69.167" style="fill:#0b9d4d;"/><path d="M109.556,231.5l0,-35.667l208.055,0l0,35.792l-208.055,0m-17.889,16.667l239.361,0c2.453,0 4.472,-1.881 4.472,-4.167l0,-60.792c0,-2.285 -2.019,-4.166 -4.472,-4.166l-239.361,0l0,69.125Z" style="fill:#d40000;fill-rule:nonzero;"/><rect x="91.667" y="89.167" width="108.333" height="69.167" style="fill:#bdac03;"/><path d="M109.556,141.667l0,-35.834l208.055,0l0,35.834l-208.055,0m-17.889,16.666l239.361,0c2.453,0 4.472,-1.881 4.472,-4.166l0,-60.834c0,-2.285 -2.019,-4.166 -4.472,-4.166l-239.361,0l0,69.166Z" style="fill:#d40000;fill-rule:nonzero;"/><rect x="91.667" y="-0.072" width="108.333" height="68.405" style="fill:#8e0000;"/><path d="M109.556,52.428l0,-35.833l208.055,0l0,35.833l-208.055,0m-17.889,16.667l239.361,0c2.453,0 4.472,-1.881 4.472,-4.167l0,-60.833c0,-2.286 -2.019,-4.167 -4.472,-4.167l-239.361,0l0,69.167Z" style="fill:#d40000;fill-rule:nonzero;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,9 +1,27 @@
import _ from 'lodash';
import { getNextRefIdChar } from './utils';
import { getDefaultTarget } from './triggers_panel_ctrl';
import { ShowProblemTypes } from '../datasource-zabbix/types';
// Actual schema version
export const CURRENT_SCHEMA_VERSION = 7;
export const CURRENT_SCHEMA_VERSION = 8;
export const getDefaultTarget = (targets?) => {
return {
group: {filter: ""},
host: {filter: ""},
application: {filter: ""},
trigger: {filter: ""},
tags: {filter: ""},
proxy: {filter: ""},
refId: getNextRefIdChar(targets),
};
};
export function getDefaultTargetOptions() {
return {
hostsInMaintenance: true,
};
}
export function migratePanelSchema(panel) {
if (isEmptyPanel(panel)) {
@@ -12,7 +30,7 @@ export function migratePanelSchema(panel) {
}
const schemaVersion = getSchemaVersion(panel);
panel.schemaVersion = CURRENT_SCHEMA_VERSION;
// panel.schemaVersion = CURRENT_SCHEMA_VERSION;
if (schemaVersion < 2) {
panel.datasources = [panel.datasource];
@@ -66,9 +84,70 @@ export function migratePanelSchema(panel) {
delete panel.datasources;
}
if (schemaVersion < 8) {
if (panel.targets.length === 1) {
if (panel.targets[0].datasource) {
panel.datasource = panel.targets[0].datasource;
delete panel.targets[0].datasource;
}
} else if (panel.targets.length > 1) {
// Mixed data sources
panel.datasource = '-- Mixed --';
}
for (const target of panel.targets) {
// set queryType to PROBLEMS
target.queryType = 5;
target.showProblems = migrateShowEvents(panel);
target.options = migrateOptions(panel);
_.defaults(target.options, getDefaultTargetOptions());
_.defaults(target, { tags: { filter: "" } });
}
panel.sortProblems = panel.sortTriggersBy?.value === 'priority' ? 'priority' : 'lastchange';
delete panel.showEvents;
delete panel.showTriggers;
delete panel.hostsInMaintenance;
delete panel.sortTriggersBy;
}
return panel;
}
function migrateOptions(panel) {
let acknowledged = 2;
if (panel.showTriggers === 'acknowledged') {
acknowledged = 1;
} else if (panel.showTriggers === 'unacknowledged') {
acknowledged = 0;
}
// Default limit in Zabbix
let limit = 1001;
if (panel.limit && panel.limit !== 100) {
limit = panel.limit;
}
return {
hostsInMaintenance: panel.hostsInMaintenance,
sortProblems: panel.sortTriggersBy?.value === 'priority' ? 'priority' : 'default',
minSeverity: 0,
acknowledged: acknowledged,
limit: limit,
};
}
function migrateShowEvents(panel) {
if (panel.showEvents?.value === 1) {
return ShowProblemTypes.Problems;
} else if (panel.showEvents?.value === 0 || panel.showEvents?.value?.length > 1) {
return ShowProblemTypes.History;
} else {
return ShowProblemTypes.Problems;
}
}
function getSchemaVersion(panel) {
return panel.schemaVersion || 1;
}

View File

@@ -11,9 +11,8 @@
* Licensed under the Apache License, Version 2.0
*/
import {TriggerPanelCtrl} from './triggers_panel_ctrl';
import {loadPluginCss} from 'grafana/app/plugins/sdk';
import './datasource-selector.directive';
import { TriggerPanelCtrl } from './triggers_panel_ctrl';
import { loadPluginCss } from 'grafana/app/plugins/sdk';
loadPluginCss({
dark: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.dark.css',

View File

@@ -29,10 +29,13 @@ class TriggerPanelOptionsCtrl {
'unacknowledged',
'acknowledged'
];
this.sortByFields = [
{ text: 'last change', value: 'lastchange' },
{ text: 'severity', value: 'priority' }
this.sortingOptions = [
{ text: 'Default', value: 'default' },
{ text: 'Last change', value: 'lastchange' },
{ text: 'Severity', value: 'priority' },
];
this.showEventsFields = [
{ text: 'All', value: [0,1] },
{ text: 'OK', value: [0] },

View File

@@ -1,6 +1,6 @@
<div class="editor-row">
<div class="section gf-form-group">
<h5 class="section-heading">Show fields</h5>
<h5 class="section-heading">Fields</h5>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Host name"
@@ -23,7 +23,7 @@
label-class="width-9"
label="Host proxy"
checked="ctrl.panel.hostProxy"
on-change="ctrl.refresh()">
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
@@ -50,6 +50,12 @@
checked="ctrl.panel.severityField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Ack"
checked="ctrl.panel.ackField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Age"
@@ -72,53 +78,6 @@
</gf-form-switch>
</div>
<div class="section gf-form-group">
<h5 class="section-heading">Options</h5>
<gf-form-switch class="gf-form"
label-class="width-15"
label="Show hosts in maintenance"
checked="ctrl.panel.hostsInMaintenance"
on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-8">Acknowledged</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input"
ng-model="ctrl.panel.showTriggers"
ng-options="f for f in editor.ackFilters"
ng-change="ctrl.refresh()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Sort by</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input"
ng-model="ctrl.panel.sortTriggersBy"
ng-options="f.text for f in editor.sortByFields track by f.value"
ng-change="ctrl.render()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Show events</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input"
ng-model="ctrl.panel.showEvents"
ng-options="f.text for f in editor.showEventsFields track by f.value"
ng-change="ctrl.refresh()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Limit triggers</label>
<input class="gf-form-input width-5"
type="number" placeholder="100"
ng-model="ctrl.panel.limit"
ng-model-onblur ng-change="ctrl.refresh()">
</div>
</div>
<div class="section gf-form-group">
<h5 class="section-heading">View options</h5>
<div class="gf-form">
@@ -130,6 +89,16 @@
ng-change="ctrl.render()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-10">Sort by</label>
<div class="gf-form-select-wrapper max-width-8">
<select class="gf-form-input"
ng-model="ctrl.panel.sortProblems"
ng-options="f.value as f.text for f in editor.sortingOptions"
ng-change="ctrl.reRenderProblems()">
</select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-10">Font size</label>
<div class="gf-form-select-wrapper max-width-8">
@@ -202,7 +171,7 @@
</div>
<div class="section gf-form-group">
<h5 class="section-heading">Triggers severity and colors</h5>
<h5 class="section-heading">Problems severity and colors</h5>
<div class="gf-form-inline" ng-repeat="trigger in ctrl.panel.triggerSeverity">
<div class="gf-form">
<label class="gf-form-label width-3">{{ trigger.priority }}</label>
@@ -224,7 +193,7 @@
label-class="width-0"
label="Show"
checked="trigger.show"
on-change="ctrl.refresh()">
on-change="ctrl.reRenderProblems()">
</gf-form-switch>
</div>
@@ -246,7 +215,7 @@
label-class="width-0"
label="Show"
checked="ctrl.panel.markAckEvents"
on-change="ctrl.refresh()">
on-change="ctrl.reRenderProblems()">
</gf-form-switch>
</div>
<div class="gf-form-inline">

View File

@@ -1,104 +0,0 @@
<div class="editor-row">
<div class="section gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-9">Data sources</label>
</div>
<div class="gf-form">
<datasource-selector
datasources="editor.selectedDatasources"
options="editor.panelCtrl.available_datasources"
on-change="editor.datasourcesChanged()">
</datasource-selector>
</div>
</div>
</div>
</div>
<div class="editor-row" ng-repeat="target in ctrl.panel.targets">
<div class="section gf-form-group">
<h5 class="section-heading">{{ target.datasource }}</h5>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Group</label>
<input type="text"
ng-model="target.group.filter"
bs-typeahead="editor.getGroupNames[target.datasource]"
ng-blur="editor.parseTarget()"
data-min-length=0
data-items=100
class="gf-form-input width-14"
ng-class="{
'zbx-variable': editor.isVariable(target.group.filter),
'zbx-regex': editor.isRegex(target.group.filter)
}">
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Host</label>
<input type="text"
ng-model="target.host.filter"
bs-typeahead="editor.getHostNames[target.datasource]"
ng-blur="editor.parseTarget()"
data-min-length=0
data-items=100
class="gf-form-input width-14"
ng-class="{
'zbx-variable': editor.isVariable(target.host.filter),
'zbx-regex': editor.isRegex(target.host.filter)
}">
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Proxy</label>
<input type="text"
ng-model="target.proxy.filter"
bs-typeahead="editor.getProxyNames[target.datasource]"
ng-blur="editor.parseTarget()"
data-min-length=0
data-items=100
class="gf-form-input width-14"
ng-class="{
'zbx-variable': editor.isVariable(target.proxy.filter),
'zbx-regex': editor.isRegex(target.proxy.filter)
}">
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Application</label>
<input type="text"
ng-model="target.application.filter"
bs-typeahead="editor.getApplicationNames[target.datasource]"
ng-blur="editor.parseTarget()"
data-min-length=0
data-items=100
class="gf-form-input width-14"
ng-class="{
'zbx-variable': editor.isVariable(target.application.filter),
'zbx-regex': editor.isRegex(target.application.filter)
}">
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Trigger</label>
<input type="text"
ng-model="target.trigger.filter"
ng-blur="editor.parseTarget()"
placeholder="trigger name"
class="gf-form-input width-14"
ng-style="target.trigger.style"
ng-class="{
'zbx-variable': editor.isVariable(target.trigger.filter),
'zbx-regex': editor.isRegex(target.trigger.filter)
}"
empty-to-null>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">Tags</label>
<input type="text" class="gf-form-input width-14"
ng-model="target.tags.filter"
ng-blur="editor.parseTarget()"
placeholder="tag1:value1, tag2:value2">
</div>
</div>
</div>
</div>

View File

@@ -3,13 +3,14 @@
"name": "Zabbix Problems",
"id": "alexanderzobnin-zabbix-triggers-panel",
"dataFormats": [],
"skipDataQuery": true,
"info": {
"author": {
"name": "Alexander Zobnin",
"url": "https://github.com/alexanderzobnin/grafana-zabbix"
},
"logos": {
"small": "img/icn-zabbix-problems-panel.svg",
"large": "img/icn-zabbix-problems-panel.svg"
}
}
}

View File

@@ -1,18 +1,23 @@
import _ from 'lodash';
import mocks from '../../test-setup/mocks';
import {TriggerPanelCtrl} from '../triggers_panel_ctrl';
import {DEFAULT_TARGET, DEFAULT_SEVERITY, PANEL_DEFAULTS} from '../triggers_panel_ctrl';
import {CURRENT_SCHEMA_VERSION} from '../migrations';
import { DEFAULT_TARGET, DEFAULT_SEVERITY, PANEL_DEFAULTS } from '../triggers_panel_ctrl';
import { CURRENT_SCHEMA_VERSION } from '../migrations';
jest.mock('@grafana/runtime', () => {
return {
getDataSourceSrv: () => ({
getMetricSources: () => {
return [{ meta: {id: 'alexanderzobnin-zabbix-datasource'}, value: {}, name: 'zabbix_default' }];
},
get: () => Promise.resolve({})
}),
};
}, {virtual: true});
describe('Triggers Panel schema migration', () => {
let ctx: any = {};
let updatePanelCtrl;
const datasourceSrvMock = {
getMetricSources: () => {
return [{ meta: {id: 'alexanderzobnin-zabbix-datasource'}, value: {}, name: 'zabbix_default' }];
},
get: () => Promise.resolve({})
};
const timeoutMock = () => {};
@@ -29,8 +34,9 @@ describe('Triggers Panel schema migration', () => {
ageField: true,
infoField: true,
limit: 10,
showTriggers: 'all triggers',
showTriggers: 'unacknowledged',
hideHostsInMaintenance: false,
hostsInMaintenance: false,
sortTriggersBy: { text: 'last change', value: 'lastchange' },
showEvents: { text: 'Problems', value: '1' },
triggerSeverity: DEFAULT_SEVERITY,
@@ -43,7 +49,7 @@ describe('Triggers Panel schema migration', () => {
}
};
updatePanelCtrl = (scope) => new TriggerPanelCtrl(scope, {}, timeoutMock, datasourceSrvMock, {}, {}, {}, mocks.timeSrvMock);
updatePanelCtrl = (scope) => new TriggerPanelCtrl(scope, {}, timeoutMock);
});
it('should update old panel schema', () => {
@@ -51,12 +57,22 @@ describe('Triggers Panel schema migration', () => {
const expected = _.defaultsDeep({
schemaVersion: CURRENT_SCHEMA_VERSION,
datasource: 'zabbix',
targets: [
{
...DEFAULT_TARGET,
datasource: 'zabbix',
queryType: 5,
showProblems: 'problems',
options: {
hostsInMaintenance: false,
acknowledged: 0,
sortProblems: 'default',
minSeverity: 0,
limit: 10,
},
}
],
sortProblems: 'lastchange',
ageField: true,
statusField: false,
severityField: false,
@@ -74,27 +90,7 @@ describe('Triggers Panel schema migration', () => {
const expected = _.defaultsDeep({
schemaVersion: CURRENT_SCHEMA_VERSION,
targets: [{
...DEFAULT_TARGET,
datasource: 'zabbix_default'
}]
}, PANEL_DEFAULTS);
expect(updatedPanelCtrl.panel).toEqual(expected);
});
it('should set default targets for new panel with empty targets', () => {
ctx.scope.panel = {
targets: []
};
const updatedPanelCtrl = updatePanelCtrl(ctx.scope);
const expected = _.defaultsDeep({
targets: [{
...DEFAULT_TARGET,
datasource: 'zabbix_default'
}]
}, PANEL_DEFAULTS);
expect(updatedPanelCtrl.panel).toEqual(expected);
});
});

View File

@@ -1,90 +1,53 @@
import _ from 'lodash';
import mocks from '../../test-setup/mocks';
import {TriggerPanelCtrl} from '../triggers_panel_ctrl';
import {PANEL_DEFAULTS, DEFAULT_TARGET} from '../triggers_panel_ctrl';
// import { create } from 'domain';
import { TriggerPanelCtrl } from '../triggers_panel_ctrl';
import { PANEL_DEFAULTS, DEFAULT_TARGET } from '../triggers_panel_ctrl';
let datasourceSrvMock, zabbixDSMock;
jest.mock('@grafana/runtime', () => {
return {
getDataSourceSrv: () => datasourceSrvMock,
};
}, {virtual: true});
describe('TriggerPanelCtrl', () => {
let ctx: any = {};
let datasourceSrvMock, zabbixDSMock;
const timeoutMock = () => {};
let createPanelCtrl;
let createPanelCtrl: () => any;
beforeEach(() => {
ctx = {scope: {panel: PANEL_DEFAULTS}};
ctx = { scope: {
panel: {
...PANEL_DEFAULTS,
sortProblems: 'lastchange',
}
}};
ctx.scope.panel.targets = [{
...DEFAULT_TARGET,
datasource: 'zabbix_default',
}];
zabbixDSMock = {
replaceTemplateVars: () => {},
zabbix: {
getTriggers: jest.fn().mockReturnValue([generateTrigger("1"), generateTrigger("1")]),
getExtendedEventData: jest.fn().mockResolvedValue([]),
getEventAlerts: jest.fn().mockResolvedValue([]),
}
};
datasourceSrvMock = {
getMetricSources: () => {
return [
{ meta: {id: 'alexanderzobnin-zabbix-datasource'}, value: {}, name: 'zabbix_default' },
{ meta: {id: 'alexanderzobnin-zabbix-datasource'}, value: {}, name: 'zabbix' },
{ meta: {id: 'graphite'}, value: {}, name: 'graphite' },
];
},
get: () => Promise.resolve(zabbixDSMock)
};
createPanelCtrl = () => new TriggerPanelCtrl(ctx.scope, {}, timeoutMock, datasourceSrvMock, {}, {}, {}, mocks.timeSrvMock);
const getTriggersResp = [
[
createTrigger({
triggerid: "1", lastchange: "1510000010", priority: 5, lastEvent: {eventid: "11"}, hosts: [{maintenance_status: '1'}]
}),
createTrigger({
triggerid: "2", lastchange: "1510000040", priority: 3, lastEvent: {eventid: "12"}
}),
],
[
createTrigger({triggerid: "3", lastchange: "1510000020", priority: 4, lastEvent: {eventid: "13"}}),
createTrigger({triggerid: "4", lastchange: "1510000030", priority: 2, lastEvent: {eventid: "14"}}),
]
];
// Simulate 2 data sources
zabbixDSMock.zabbix.getTriggers = jest.fn()
.mockReturnValueOnce(getTriggersResp[0])
.mockReturnValueOnce(getTriggersResp[1]);
zabbixDSMock.zabbix.getExtendedEventData = jest.fn()
.mockReturnValue(Promise.resolve([defaultEvent]));
const timeoutMock = (fn: () => any) => Promise.resolve(fn());
createPanelCtrl = () => new TriggerPanelCtrl(ctx.scope, {}, timeoutMock);
ctx.panelCtrl = createPanelCtrl();
});
describe('When adding new panel', () => {
it('should suggest all zabbix data sources', () => {
ctx.scope.panel = {};
const panelCtrl = createPanelCtrl();
expect(panelCtrl.available_datasources).toEqual([
'zabbix_default', 'zabbix'
]);
});
it('should load first zabbix data source as default', () => {
ctx.scope.panel = {};
const panelCtrl = createPanelCtrl();
expect(panelCtrl.panel.targets[0].datasource).toEqual('zabbix_default');
});
it('should rewrite default empty target', () => {
ctx.scope.panel = {
targets: [{
"target": "",
"refId": "A"
}],
};
const panelCtrl = createPanelCtrl();
expect(panelCtrl.available_datasources).toEqual([
'zabbix_default', 'zabbix'
]);
});
ctx.dataFramesReceived = generateDataFramesResponse([
{id: "1", lastchange: "1510000010", priority: 5},
{id: "2", lastchange: "1510000040", priority: 3},
{id: "3", lastchange: "1510000020", priority: 4},
{id: "4", lastchange: "1510000030", priority: 2},
]);
});
describe('When refreshing panel', () => {
@@ -104,8 +67,8 @@ describe('TriggerPanelCtrl', () => {
});
it('should format triggers', (done) => {
ctx.panelCtrl.onRefresh().then(() => {
const formattedTrigger: any = _.find(ctx.panelCtrl.triggerList, {triggerid: "1"});
ctx.panelCtrl.onDataFramesReceived(ctx.dataFramesReceived).then(() => {
const formattedTrigger: any = _.find(ctx.panelCtrl.renderData, {triggerid: "1"});
expect(formattedTrigger.host).toBe('backend01');
expect(formattedTrigger.hostTechName).toBe('backend01_tech');
expect(formattedTrigger.datasource).toBe('zabbix_default');
@@ -116,8 +79,8 @@ describe('TriggerPanelCtrl', () => {
});
it('should sort triggers by time by default', (done) => {
ctx.panelCtrl.onRefresh().then(() => {
const trigger_ids = _.map(ctx.panelCtrl.triggerList, 'triggerid');
ctx.panelCtrl.onDataFramesReceived(ctx.dataFramesReceived).then(() => {
const trigger_ids = _.map(ctx.panelCtrl.renderData, 'triggerid');
expect(trigger_ids).toEqual([
'2', '4', '3', '1'
]);
@@ -126,175 +89,119 @@ describe('TriggerPanelCtrl', () => {
});
it('should sort triggers by severity', (done) => {
ctx.panelCtrl.panel.sortTriggersBy = { text: 'severity', value: 'priority' };
ctx.panelCtrl.onRefresh().then(() => {
const trigger_ids = _.map(ctx.panelCtrl.triggerList, 'triggerid');
ctx.panelCtrl.panel.sortProblems = 'priority';
ctx.panelCtrl.onDataFramesReceived(ctx.dataFramesReceived).then(() => {
const trigger_ids = _.map(ctx.panelCtrl.renderData, 'triggerid');
expect(trigger_ids).toEqual([
'1', '3', '2', '4'
]);
done();
});
});
it('should add acknowledges to trigger', (done) => {
ctx.panelCtrl.onRefresh().then(() => {
const trigger = getTriggerById(1, ctx);
expect(trigger.acknowledges).toHaveLength(1);
expect(trigger.acknowledges[0].message).toBe("event ack");
expect(getTriggerById(2, ctx).acknowledges).toBe(undefined);
expect(getTriggerById(3, ctx).acknowledges).toBe(undefined);
expect(getTriggerById(4, ctx).acknowledges).toBe(undefined);
done();
});
});
});
describe('When formatting triggers', () => {
beforeEach(() => {
ctx.panelCtrl = createPanelCtrl();
});
it('should handle new lines in trigger description', () => {
ctx.panelCtrl.setTriggerSeverity = jest.fn((trigger) => trigger);
const trigger = {comments: "this is\ndescription"};
const formattedTrigger = ctx.panelCtrl.formatTrigger(trigger);
expect(formattedTrigger.comments).toBe("this is<br>description");
});
it('should format host name to display (default)', (done) => {
ctx.panelCtrl.onRefresh().then(() => {
const trigger = getTriggerById(1, ctx);
const hostname = ctx.panelCtrl.formatHostName(trigger);
expect(hostname).toBe('backend01');
done();
});
});
it('should format host name to display (tech name)', (done) => {
ctx.panelCtrl.panel.hostField = false;
ctx.panelCtrl.panel.hostTechNameField = true;
ctx.panelCtrl.onRefresh().then(() => {
const trigger = getTriggerById(1, ctx);
const hostname = ctx.panelCtrl.formatHostName(trigger);
expect(hostname).toBe('backend01_tech');
done();
});
});
it('should format host name to display (both tech and visible)', (done) => {
ctx.panelCtrl.panel.hostField = true;
ctx.panelCtrl.panel.hostTechNameField = true;
ctx.panelCtrl.onRefresh().then(() => {
const trigger = getTriggerById(1, ctx);
const hostname = ctx.panelCtrl.formatHostName(trigger);
expect(hostname).toBe('backend01 (backend01_tech)');
done();
});
});
it('should hide hostname if both visible and tech name checkboxes unset', (done) => {
ctx.panelCtrl.panel.hostField = false;
ctx.panelCtrl.panel.hostTechNameField = false;
ctx.panelCtrl.onRefresh().then(() => {
const trigger = getTriggerById(1, ctx);
const hostname = ctx.panelCtrl.formatHostName(trigger);
expect(hostname).toBe("");
done();
});
});
});
describe('When formatting acknowledges', () => {
beforeEach(() => {
ctx.panelCtrl = createPanelCtrl();
});
it('should build proper user name', () => {
const ack = {
alias: 'alias', name: 'name', surname: 'surname'
};
const formatted = ctx.panelCtrl.formatAcknowledge(ack);
expect(formatted.user).toBe('alias (name surname)');
});
it('should return empty name if it is not defined', () => {
const formatted = ctx.panelCtrl.formatAcknowledge({});
expect(formatted.user).toBe('');
});
});
});
const defaultTrigger: any = {
"triggerid": "13565",
"value": "1",
"groups": [{"groupid": "1", "name": "Backend"}] ,
"hosts": [{"host": "backend01_tech", "hostid": "10001","maintenance_status": "0", "name": "backend01"}] ,
"lastEvent": {
"eventid": "11",
"clock": "1507229064",
"ns": "556202037",
"acknowledged": "1",
"value": "1",
"object": "0",
"source": "0",
"objectid": "13565",
},
"tags": [] ,
"lastchange": "1440259530",
"priority": "2",
"description": "Lack of free swap space on server",
const defaultProblem: any = {
"acknowledges": [],
"comments": "It probably means that the systems requires\nmore physical memory.",
"url": "https://host.local/path",
"templateid": "0", "expression": "{13174}<50", "manual_close": "0", "correlation_mode": "0",
"correlation_tag": "", "recovery_mode": "0", "recovery_expression": "", "state": "0", "status": "0",
"flags": "0", "type": "0", "items": [] , "error": ""
};
const defaultEvent: any = {
"eventid": "11",
"acknowledges": [
"correlation_mode": "0",
"correlation_tag": "",
"datasource": "zabbix_default",
"description": "Lack of free swap space on server",
"error": "",
"expression": "{13297}>20",
"flags": "0",
"groups": [
{
"acknowledgeid": "185",
"action": "0",
"alias": "api",
"clock": "1512382246",
"eventid": "11",
"message": "event ack",
"name": "api",
"surname": "user",
"userid": "3"
"groupid": "2",
"name": "Linux servers"
},
{
"groupid": "9",
"name": "Backend"
}
],
"clock": "1507229064",
"ns": "556202037",
"acknowledged": "1",
"value": "1",
"object": "0",
"source": "0",
"objectid": "1",
"hosts": [
{
"host": "backend01_tech",
"hostid": "10111",
"maintenance_status": "1",
"name": "backend01",
"proxy_hostid": "0"
}
],
"items": [
{
"itemid": "23979",
"key_": "system.cpu.util[,iowait]",
"lastvalue": "25.2091",
"name": "CPU $2 time"
}
],
"lastEvent": {
"acknowledged": "0",
"clock": "1589297010",
"eventid": "4399289",
"name": "Disk I/O is overloaded on backend01",
"ns": "224779201",
"object": "0",
"objectid": "13682",
"severity": "2",
"source": "0",
"value": "1"
},
"lastchange": "1440259530",
"maintenance": true,
"manual_close": "0",
"priority": "2",
"recovery_expression": "",
"recovery_mode": "0",
"showAckButton": true,
"state": "0",
"status": "0",
"tags": [],
"templateid": "13671",
"triggerid": "13682",
"type": "0",
"url": "",
"value": "1"
};
function generateTrigger(id, timestamp?, severity?): any {
const trigger = _.cloneDeep(defaultTrigger);
trigger.triggerid = id.toString();
function generateDataFramesResponse(problemDescs: any[] = [{id: 1}]): any {
const problems = problemDescs.map(problem => generateProblem(problem.id, problem.lastchange, problem.priority));
return [
{
"fields": [
{
"config": {},
"name": "Problems",
"state": {
"scopedVars": {},
"title": null
},
"type": "other",
"values": problems,
}
],
"length": 16,
"name": "problems"
}
];
}
function generateProblem(id, timestamp?, severity?): any {
const problem = _.cloneDeep(defaultProblem);
problem.triggerid = id.toString();
if (severity) {
trigger.priority = severity.toString();
problem.priority = severity.toString();
}
if (timestamp) {
trigger.lastchange = timestamp;
problem.lastchange = timestamp;
}
return trigger;
return problem;
}
function createTrigger(props): any {
let trigger = _.cloneDeep(defaultTrigger);
trigger = _.merge(trigger, props);
trigger.lastEvent.objectid = trigger.triggerid;
return trigger;
}
function getTriggerById(id, ctx): any {
return _.find(ctx.panelCtrl.triggerList, {triggerid: id.toString()});
function getProblemById(id, ctx): any {
return _.find(ctx.panelCtrl.renderData, {triggerid: id.toString()});
}

View File

@@ -1,725 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import moment from 'moment';
import * as dateMath from 'grafana/app/core/utils/datemath';
import * as utils from '../datasource-zabbix/utils';
import { PanelCtrl } from 'grafana/app/plugins/sdk';
import { triggerPanelOptionsTab } from './options_tab';
import { triggerPanelTriggersTab } from './triggers_tab';
import { migratePanelSchema, CURRENT_SCHEMA_VERSION } from './migrations';
import ProblemList from './components/Problems/Problems';
import AlertList from './components/AlertList/AlertList';
import { getNextRefIdChar } from './utils';
const ZABBIX_DS_ID = 'alexanderzobnin-zabbix-datasource';
const PROBLEM_EVENTS_LIMIT = 100;
export const DEFAULT_TARGET = {
group: {filter: ""},
host: {filter: ""},
application: {filter: ""},
trigger: {filter: ""},
tags: {filter: ""},
proxy: {filter: ""},
};
export const getDefaultTarget = (targets) => {
return {
group: {filter: ""},
host: {filter: ""},
application: {filter: ""},
trigger: {filter: ""},
tags: {filter: ""},
proxy: {filter: ""},
refId: getNextRefIdChar(targets),
};
};
export const DEFAULT_SEVERITY = [
{ priority: 0, severity: 'Not classified', color: 'rgb(108, 108, 108)', show: true},
{ priority: 1, severity: 'Information', color: 'rgb(120, 158, 183)', show: true},
{ priority: 2, severity: 'Warning', color: 'rgb(175, 180, 36)', show: true},
{ priority: 3, severity: 'Average', color: 'rgb(255, 137, 30)', show: true},
{ priority: 4, severity: 'High', color: 'rgb(255, 101, 72)', show: true},
{ priority: 5, severity: 'Disaster', color: 'rgb(215, 0, 0)', show: true},
];
export const getDefaultSeverity = () => DEFAULT_SEVERITY;
const DEFAULT_TIME_FORMAT = "DD MMM YYYY HH:mm:ss";
export const PANEL_DEFAULTS = {
schemaVersion: CURRENT_SCHEMA_VERSION,
targets: [getDefaultTarget([])],
// Fields
hostField: true,
hostTechNameField: false,
hostGroups: false,
hostProxy: false,
showTags: true,
statusField: true,
statusIcon: false,
severityField: true,
ageField: false,
descriptionField: true,
descriptionAtNewLine: false,
// Options
hostsInMaintenance: true,
showTriggers: 'all triggers',
sortTriggersBy: { text: 'last change', value: 'lastchange' },
showEvents: { text: 'Problems', value: 1 },
limit: 100,
// View options
layout: 'table',
fontSize: '100%',
pageSize: 10,
problemTimeline: true,
highlightBackground: false,
highlightNewEvents: false,
highlightNewerThan: '1h',
customLastChangeFormat: false,
lastChangeFormat: "",
resizedColumns: [],
// Triggers severity and colors
triggerSeverity: getDefaultSeverity(),
okEventColor: 'rgb(56, 189, 113)',
ackEventColor: 'rgb(56, 219, 156)',
markAckEvents: false,
};
const triggerStatusMap = {
'0': 'OK',
'1': 'PROBLEM'
};
export class TriggerPanelCtrl extends PanelCtrl {
/** @ngInject */
constructor($scope, $injector, $timeout, datasourceSrv, templateSrv, contextSrv, dashboardSrv, timeSrv) {
super($scope, $injector);
this.datasourceSrv = datasourceSrv;
this.templateSrv = templateSrv;
this.contextSrv = contextSrv;
this.dashboardSrv = dashboardSrv;
this.timeSrv = timeSrv;
this.scope = $scope;
this.$timeout = $timeout;
this.editorTabIndex = 1;
this.triggerStatusMap = triggerStatusMap;
this.defaultTimeFormat = DEFAULT_TIME_FORMAT;
this.pageIndex = 0;
this.triggerList = [];
this.datasources = {};
this.range = {};
this.panel = migratePanelSchema(this.panel);
_.defaultsDeep(this.panel, _.cloneDeep(PANEL_DEFAULTS));
this.available_datasources = _.map(this.getZabbixDataSources(), 'name');
if (this.panel.targets && !this.panel.targets[0].datasource) {
this.panel.targets[0].datasource = this.available_datasources[0];
}
this.initDatasources();
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
this.events.on('refresh', this.onRefresh.bind(this));
}
setPanelError(err, defaultError) {
const defaultErrorMessage = defaultError || "Request Error";
this.inspector = { error: err };
this.error = err.message || defaultErrorMessage;
if (err.data) {
if (err.data.message) {
this.error = err.data.message;
}
if (err.data.error) {
this.error = err.data.error;
}
}
this.events.emit('data-error', err);
console.log('Panel data error:', err);
}
initDatasources() {
if (!this.panel.targets) {
return;
}
const targetDatasources = _.compact(this.panel.targets.map(target => target.datasource));
let promises = targetDatasources.map(ds => {
// Load datasource
return this.datasourceSrv.get(ds)
.then(datasource => {
this.datasources[ds] = datasource;
return datasource;
});
});
return Promise.all(promises);
}
getZabbixDataSources() {
return _.filter(this.datasourceSrv.getMetricSources(), datasource => {
return datasource.meta.id === ZABBIX_DS_ID && datasource.value;
});
}
isEmptyTargets() {
const emptyTargets = _.isEmpty(this.panel.targets);
const emptyTarget = (this.panel.targets.length === 1 && (
_.isEmpty(this.panel.targets[0]) ||
this.panel.targets[0].target === ""
));
return emptyTargets || emptyTarget;
}
onInitEditMode() {
this.addEditorTab('Triggers', triggerPanelTriggersTab, 1);
this.addEditorTab('Options', triggerPanelOptionsTab, 2);
}
setTimeQueryStart() {
this.timing.queryStart = new Date().getTime();
}
setTimeQueryEnd() {
this.timing.queryEnd = (new Date()).getTime();
}
onRefresh() {
// ignore fetching data if another panel is in fullscreen
if (this.otherPanelInFullscreenMode()) { return; }
this.range = this.timeSrv.timeRange();
// clear loading/error state
delete this.error;
this.loading = true;
this.setTimeQueryStart();
this.pageIndex = 0;
return this.getTriggers()
.then(triggers => {
// Notify panel that request is finished
this.loading = false;
this.setTimeQueryEnd();
return this.renderTriggers(triggers);
})
.then(() => {
this.$timeout(() => {
this.renderingCompleted();
});
})
.catch(err => {
this.loading = false;
if (err.cancelled) {
console.log('Panel request cancelled', err);
return;
}
this.setPanelError(err);
});
}
renderTriggers(zabbixTriggers) {
let triggers = _.cloneDeep(zabbixTriggers || this.triggerListUnfiltered);
this.triggerListUnfiltered = _.cloneDeep(triggers);
triggers = _.map(triggers, this.formatTrigger.bind(this));
triggers = this.filterTriggersPost(triggers);
triggers = this.sortTriggers(triggers);
// Limit triggers number
triggers = triggers.slice(0, this.panel.limit || PANEL_DEFAULTS.limit);
this.triggerList = triggers;
return this.$timeout(() => {
return super.render(this.triggerList);
});
}
getTriggers() {
const timeFrom = Math.ceil(dateMath.parse(this.range.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(this.range.to) / 1000);
const userIsEditor = this.contextSrv.isEditor || this.contextSrv.isGrafanaAdmin;
let promises = _.map(this.panel.targets, (target) => {
const ds = target.datasource;
let proxies;
let showAckButton = true;
return this.datasourceSrv.get(ds)
.then(datasource => {
const zabbix = datasource.zabbix;
const showEvents = this.panel.showEvents.value;
const triggerFilter = target;
const showProxy = this.panel.hostProxy;
const getProxiesPromise = showProxy ? zabbix.getProxies() : () => [];
showAckButton = !datasource.disableReadOnlyUsersAck || userIsEditor;
// Replace template variables
const groupFilter = datasource.replaceTemplateVars(triggerFilter.group.filter);
const hostFilter = datasource.replaceTemplateVars(triggerFilter.host.filter);
const appFilter = datasource.replaceTemplateVars(triggerFilter.application.filter);
const proxyFilter = datasource.replaceTemplateVars(triggerFilter.proxy.filter);
let triggersOptions = {
showTriggers: showEvents
};
if (showEvents !== 1) {
triggersOptions.timeFrom = timeFrom;
triggersOptions.timeTo = timeTo;
}
return Promise.all([
zabbix.getTriggers(groupFilter, hostFilter, appFilter, triggersOptions, proxyFilter),
getProxiesPromise
]);
}).then(([triggers, sourceProxies]) => {
proxies = _.keyBy(sourceProxies, 'proxyid');
const eventids = _.compact(triggers.map(trigger => {
return trigger.lastEvent.eventid;
}));
return Promise.all([
this.datasources[ds].zabbix.getExtendedEventData(eventids),
Promise.resolve(triggers)
]);
})
.then(([events, triggers]) => {
this.addEventTags(events, triggers);
this.addAcknowledges(events, triggers);
return triggers;
})
.then(triggers => this.setMaintenanceStatus(triggers))
.then(triggers => this.setAckButtonStatus(triggers, showAckButton))
.then(triggers => this.filterTriggersPre(triggers, target))
.then(triggers => this.addTriggerDataSource(triggers, target))
.then(triggers => this.addTriggerHostProxy(triggers, proxies));
});
return Promise.all(promises)
.then(results => _.flatten(results));
}
addAcknowledges(events, triggers) {
// Map events to triggers
_.each(triggers, trigger => {
var event = _.find(events, event => {
return event.eventid === trigger.lastEvent.eventid;
});
if (event) {
trigger.acknowledges = _.map(event.acknowledges, this.formatAcknowledge.bind(this));
}
if (!trigger.lastEvent.eventid) {
trigger.lastEvent = null;
}
});
return triggers;
}
formatAcknowledge(ack) {
let timestamp = moment.unix(ack.clock);
if (this.panel.customLastChangeFormat) {
ack.time = timestamp.format(this.panel.lastChangeFormat);
} else {
ack.time = timestamp.format(this.defaultTimeFormat);
}
ack.user = ack.alias || '';
if (ack.name || ack.surname) {
const fullName = `${ack.name || ''} ${ack.surname || ''}`;
ack.user += ` (${fullName})`;
}
return ack;
}
addEventTags(events, triggers) {
_.each(triggers, trigger => {
var event = _.find(events, event => {
return event.eventid === trigger.lastEvent.eventid;
});
if (event && event.tags && event.tags.length) {
trigger.tags = event.tags;
}
});
return triggers;
}
filterTriggersPre(triggerList, target) {
// Filter triggers by description
const ds = target.datasource;
let triggerFilter = target.trigger.filter;
triggerFilter = this.datasources[ds].replaceTemplateVars(triggerFilter);
if (triggerFilter) {
triggerList = filterTriggers(triggerList, triggerFilter);
}
// Filter by tags
// const target = this.panel.targets[ds];
if (target.tags.filter) {
let tagsFilter = this.datasources[ds].replaceTemplateVars(target.tags.filter);
// replaceTemplateVars() builds regex-like string, so we should trim it.
tagsFilter = tagsFilter.replace('/^', '').replace('$/', '');
const tags = this.parseTags(tagsFilter);
triggerList = _.filter(triggerList, trigger => {
return _.every(tags, (tag) => {
return _.find(trigger.tags, {tag: tag.tag, value: tag.value});
});
});
}
return triggerList;
}
filterTriggersPost(triggers) {
let triggerList = _.cloneDeep(triggers);
// Filter acknowledged triggers
if (this.panel.showTriggers === 'unacknowledged') {
triggerList = _.filter(triggerList, trigger => {
return !(trigger.acknowledges && trigger.acknowledges.length);
});
} else if (this.panel.showTriggers === 'acknowledged') {
triggerList = _.filter(triggerList, trigger => {
return trigger.acknowledges && trigger.acknowledges.length;
});
}
// Filter by maintenance status
if (!this.panel.hostsInMaintenance) {
triggerList = _.filter(triggerList, (trigger) => trigger.maintenance === false);
}
// Filter triggers by severity
triggerList = _.filter(triggerList, trigger => {
return this.panel.triggerSeverity[trigger.priority].show;
});
return triggerList;
}
setMaintenanceStatus(triggers) {
_.each(triggers, (trigger) => {
let maintenance_status = _.some(trigger.hosts, (host) => host.maintenance_status === '1');
trigger.maintenance = maintenance_status;
});
return triggers;
}
setAckButtonStatus(triggers, showAckButton) {
_.each(triggers, (trigger) => {
trigger.showAckButton = showAckButton;
});
return triggers;
}
addTriggerDataSource(triggers, target) {
_.each(triggers, (trigger) => {
trigger.datasource = target.datasource;
});
return triggers;
}
addTriggerHostProxy(triggers, proxies) {
triggers.forEach(trigger => {
if (trigger.hosts && trigger.hosts.length) {
let host = trigger.hosts[0];
if (host.proxy_hostid !== '0') {
const hostProxy = proxies[host.proxy_hostid];
host.proxy = hostProxy ? hostProxy.host : '';
}
}
});
return triggers;
}
sortTriggers(triggerList) {
if (this.panel.sortTriggersBy.value === 'priority') {
triggerList = _.orderBy(triggerList, ['priority', 'lastchangeUnix', 'triggerid'], ['desc', 'desc', 'desc']);
} else {
triggerList = _.orderBy(triggerList, ['lastchangeUnix', 'priority', 'triggerid'], ['desc', 'desc', 'desc']);
}
return triggerList;
}
formatTrigger(zabbixTrigger) {
let trigger = _.cloneDeep(zabbixTrigger);
// Set host and proxy that the trigger belongs
if (trigger.hosts && trigger.hosts.length) {
const host = trigger.hosts[0];
trigger.host = host.name;
trigger.hostTechName = host.host;
if (host.proxy) {
trigger.proxy = host.proxy;
}
}
// Set tags if present
if (trigger.tags && trigger.tags.length === 0) {
trigger.tags = null;
}
// Handle multi-line description
if (trigger.comments) {
trigger.comments = trigger.comments.replace('\n', '<br>');
}
trigger.lastchangeUnix = Number(trigger.lastchange);
return trigger;
}
parseTags(tagStr) {
if (!tagStr) {
return [];
}
let tags = _.map(tagStr.split(','), (tag) => tag.trim());
tags = _.map(tags, (tag) => {
const tagParts = tag.split(':');
return {tag: tagParts[0].trim(), value: tagParts[1].trim()};
});
return tags;
}
tagsToString(tags) {
return _.map(tags, (tag) => `${tag.tag}:${tag.value}`).join(', ');
}
addTagFilter(tag, datasource) {
const target = this.panel.targets.find(t => t.datasource === datasource);
console.log(target);
let tagFilter = target.tags.filter;
let targetTags = this.parseTags(tagFilter);
let newTag = {tag: tag.tag, value: tag.value};
targetTags.push(newTag);
targetTags = _.uniqWith(targetTags, _.isEqual);
let newFilter = this.tagsToString(targetTags);
target.tags.filter = newFilter;
this.refresh();
}
removeTagFilter(tag, datasource) {
const target = this.panel.targets.find(t => t.datasource === datasource);
let tagFilter = target.tags.filter;
let targetTags = this.parseTags(tagFilter);
_.remove(targetTags, t => t.tag === tag.tag && t.value === tag.value);
targetTags = _.uniqWith(targetTags, _.isEqual);
let newFilter = this.tagsToString(targetTags);
target.tags.filter = newFilter;
this.refresh();
}
getProblemEvents(problem) {
const triggerids = [problem.triggerid];
const timeFrom = Math.ceil(dateMath.parse(this.range.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(this.range.to) / 1000);
return this.datasourceSrv.get(problem.datasource)
.then(datasource => {
return datasource.zabbix.getEvents(triggerids, timeFrom, timeTo, [0, 1], PROBLEM_EVENTS_LIMIT);
});
}
getProblemAlerts(problem) {
if (!problem.lastEvent || problem.lastEvent.length === 0) {
return Promise.resolve([]);
}
const eventids = [problem.lastEvent.eventid];
return this.datasourceSrv.get(problem.datasource)
.then(datasource => {
return datasource.zabbix.getEventAlerts(eventids);
});
}
formatHostName(trigger) {
let host = "";
if (this.panel.hostField && this.panel.hostTechNameField) {
host = `${trigger.host} (${trigger.hostTechName})`;
} else if (this.panel.hostField || this.panel.hostTechNameField) {
host = this.panel.hostField ? trigger.host : trigger.hostTechName;
}
if (this.panel.hostProxy && trigger.proxy) {
host = `${trigger.proxy}: ${host}`;
}
return host;
}
formatHostGroups(trigger) {
let groupNames = "";
if (this.panel.hostGroups) {
let groups = _.map(trigger.groups, 'name').join(', ');
groupNames += `[ ${groups} ]`;
}
return groupNames;
}
isNewTrigger(trigger) {
try {
const highlightIntervalMs = utils.parseInterval(this.panel.highlightNewerThan || PANEL_DEFAULTS.highlightNewerThan);
const durationSec = (Date.now() - trigger.lastchangeUnix * 1000);
return durationSec < highlightIntervalMs;
} catch (e) {
return false;
}
}
getAlertIconClass(trigger) {
let iconClass = '';
if (trigger.value === '1' && trigger.priority >= 2) {
iconClass = 'icon-gf-critical';
} else {
iconClass = 'icon-gf-online';
}
if (this.panel.highlightNewEvents && this.isNewTrigger(trigger)) {
iconClass += ' zabbix-trigger--blinked';
}
return iconClass;
}
getAlertIconClassBySeverity(triggerSeverity) {
let iconClass = 'icon-gf-online';
if (triggerSeverity.priority >= 2) {
iconClass = 'icon-gf-critical';
}
return iconClass;
}
getAlertStateClass(trigger) {
let statusClass = '';
if (trigger.value === '1') {
statusClass = 'alert-state-critical';
} else {
statusClass = 'alert-state-ok';
}
if (this.panel.highlightNewEvents && this.isNewTrigger(trigger)) {
statusClass += ' zabbix-trigger--blinked';
}
return statusClass;
}
resetResizedColumns() {
this.panel.resizedColumns = [];
this.render();
}
acknowledgeTrigger(trigger, message) {
let eventid = trigger.lastEvent ? trigger.lastEvent.eventid : null;
let grafana_user = this.contextSrv.user.name;
let ack_message = grafana_user + ' (Grafana): ' + message;
return this.datasourceSrv.get(trigger.datasource)
.then(datasource => {
const userIsEditor = this.contextSrv.isEditor || this.contextSrv.isGrafanaAdmin;
if (datasource.disableReadOnlyUsersAck && !userIsEditor) {
return Promise.reject({message: 'You have no permissions to acknowledge events.'});
}
if (eventid) {
return datasource.zabbix.acknowledgeEvent(eventid, ack_message);
} else {
return Promise.reject({message: 'Trigger has no events. Nothing to acknowledge.'});
}
})
.then(this.onRefresh.bind(this))
.catch((err) => {
this.setPanelError(err);
});
}
handlePageSizeChange(pageSize, pageIndex) {
this.panel.pageSize = pageSize;
this.pageIndex = pageIndex;
this.scope.$apply(() => {
this.render();
});
}
handleColumnResize(newResized) {
this.panel.resizedColumns = newResized;
this.scope.$apply(() => {
this.render();
});
}
link(scope, elem, attrs, ctrl) {
let panel = ctrl.panel;
let triggerList = ctrl.triggerList;
scope.$watchGroup(['ctrl.triggerList'], renderPanel);
ctrl.events.on('render', (renderData) => {
triggerList = renderData || triggerList;
renderPanel();
});
function renderPanel() {
const timeFrom = Math.ceil(dateMath.parse(ctrl.range.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(ctrl.range.to) / 1000);
const fontSize = parseInt(panel.fontSize.slice(0, panel.fontSize.length - 1));
const fontSizeProp = fontSize && fontSize !== 100 ? fontSize : null;
const pageSize = panel.pageSize || 10;
const loading = ctrl.loading && (!ctrl.triggerList || !ctrl.triggerList.length);
let panelOptions = {};
for (let prop in PANEL_DEFAULTS) {
panelOptions[prop] = ctrl.panel[prop];
}
const problemsListProps = {
problems: ctrl.triggerList,
panelOptions,
timeRange: { timeFrom, timeTo },
loading,
pageSize,
fontSize: fontSizeProp,
getProblemEvents: ctrl.getProblemEvents.bind(ctrl),
getProblemAlerts: ctrl.getProblemAlerts.bind(ctrl),
onPageSizeChange: ctrl.handlePageSizeChange.bind(ctrl),
onColumnResize: ctrl.handleColumnResize.bind(ctrl),
onProblemAck: (trigger, data) => {
const message = data.message;
return ctrl.acknowledgeTrigger(trigger, message);
},
onTagClick: (tag, datasource, ctrlKey, shiftKey) => {
if (ctrlKey || shiftKey) {
ctrl.removeTagFilter(tag, datasource);
} else {
ctrl.addTagFilter(tag, datasource);
}
}
};
let problemsReactElem;
if (panel.layout === 'list') {
problemsReactElem = React.createElement(AlertList, problemsListProps);
} else {
problemsReactElem = React.createElement(ProblemList, problemsListProps);
}
ReactDOM.render(problemsReactElem, elem.find('.panel-content')[0]);
}
}
}
TriggerPanelCtrl.templateUrl = 'public/plugins/alexanderzobnin-zabbix-app/panel-triggers/partials/module.html';
function filterTriggers(triggers, triggerFilter) {
if (utils.isRegex(triggerFilter)) {
return _.filter(triggers, function(trigger) {
return utils.buildRegex(triggerFilter).test(trigger.description);
});
} else {
return _.filter(triggers, function(trigger) {
return trigger.description === triggerFilter;
});
}
}

View File

@@ -0,0 +1,434 @@
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import { getDataSourceSrv } from '@grafana/runtime';
import { PanelEvents } from '@grafana/data';
import * as dateMath from 'grafana/app/core/utils/datemath';
import * as utils from '../datasource-zabbix/utils';
import { MetricsPanelCtrl } from 'grafana/app/plugins/sdk';
import { triggerPanelOptionsTab } from './options_tab';
import { migratePanelSchema, CURRENT_SCHEMA_VERSION } from './migrations';
import ProblemList from './components/Problems/Problems';
import AlertList from './components/AlertList/AlertList';
import { ProblemDTO } from 'datasource-zabbix/types';
const PROBLEM_EVENTS_LIMIT = 100;
export const DEFAULT_TARGET = {
group: {filter: ""},
host: {filter: ""},
application: {filter: ""},
trigger: {filter: ""},
tags: {filter: ""},
proxy: {filter: ""},
showProblems: 'problems',
};
export const DEFAULT_SEVERITY = [
{ priority: 0, severity: 'Not classified', color: 'rgb(108, 108, 108)', show: true},
{ priority: 1, severity: 'Information', color: 'rgb(120, 158, 183)', show: true},
{ priority: 2, severity: 'Warning', color: 'rgb(175, 180, 36)', show: true},
{ priority: 3, severity: 'Average', color: 'rgb(255, 137, 30)', show: true},
{ priority: 4, severity: 'High', color: 'rgb(255, 101, 72)', show: true},
{ priority: 5, severity: 'Disaster', color: 'rgb(215, 0, 0)', show: true},
];
export const getDefaultSeverity = () => DEFAULT_SEVERITY;
const DEFAULT_TIME_FORMAT = "DD MMM YYYY HH:mm:ss";
export const PANEL_DEFAULTS = {
schemaVersion: CURRENT_SCHEMA_VERSION,
// Fields
hostField: true,
hostTechNameField: false,
hostProxy: false,
hostGroups: false,
showTags: true,
statusField: true,
statusIcon: false,
severityField: true,
ackField: true,
ageField: false,
descriptionField: true,
descriptionAtNewLine: false,
// Options
sortProblems: 'lastchange',
limit: null,
// View options
layout: 'table',
fontSize: '100%',
pageSize: 10,
problemTimeline: true,
highlightBackground: false,
highlightNewEvents: false,
highlightNewerThan: '1h',
customLastChangeFormat: false,
lastChangeFormat: "",
resizedColumns: [],
// Triggers severity and colors
triggerSeverity: getDefaultSeverity(),
okEventColor: 'rgb(56, 189, 113)',
ackEventColor: 'rgb(56, 219, 156)',
markAckEvents: false,
};
const triggerStatusMap = {
'0': 'OK',
'1': 'PROBLEM'
};
export class TriggerPanelCtrl extends MetricsPanelCtrl {
scope: any;
useDataFrames: boolean;
triggerStatusMap: any;
defaultTimeFormat: string;
pageIndex: number;
renderData: any[];
problems: any[];
contextSrv: any;
static templateUrl: string;
/** @ngInject */
constructor($scope, $injector, $timeout) {
super($scope, $injector);
this.scope = $scope;
this.$timeout = $timeout;
// Tell Grafana do not convert data frames to table or series
this.useDataFrames = true;
this.triggerStatusMap = triggerStatusMap;
this.defaultTimeFormat = DEFAULT_TIME_FORMAT;
this.pageIndex = 0;
this.range = {};
this.renderData = [];
this.panel = migratePanelSchema(this.panel);
_.defaultsDeep(this.panel, _.cloneDeep(PANEL_DEFAULTS));
// this.events.on(PanelEvents.render, this.onRender.bind(this));
this.events.on('data-frames-received', this.onDataFramesReceived.bind(this));
this.events.on(PanelEvents.dataSnapshotLoad, this.onDataSnapshotLoad.bind(this));
this.events.on(PanelEvents.editModeInitialized, this.onInitEditMode.bind(this));
}
onInitEditMode() {
// Update schema version to prevent migration on up-to-date targets
this.panel.schemaVersion = CURRENT_SCHEMA_VERSION;
this.addEditorTab('Options', triggerPanelOptionsTab);
}
onDataFramesReceived(data: any): Promise<any> {
this.range = this.timeSrv.timeRange();
let problems = [];
if (data && data.length) {
for (const dataFrame of data) {
try {
const values = dataFrame.fields[0].values;
if (values.toArray) {
problems.push(values.toArray());
} else if (values.length > 0) {
// On snapshot mode values is a plain Array, not ArrayVector
problems.push(values);
}
} catch (error) {
console.log(error);
return Promise.reject(error);
}
}
}
this.loading = false;
problems = _.flatten(problems);
this.problems = problems;
return this.renderProblems(problems);
}
onDataSnapshotLoad(snapshotData) {
return this.onDataFramesReceived(snapshotData);
}
reRenderProblems() {
if (this.problems) {
this.renderProblems(this.problems);
}
}
setPanelError(err, defaultError = "Request Error") {
this.inspector = { error: err };
this.error = err.message || defaultError;
if (err.data) {
if (err.data.message) {
this.error = err.data.message;
}
if (err.data.error) {
this.error = err.data.error;
}
}
// this.events.emit(PanelEvents.dataError, err);
console.log('Panel data error:', err);
}
renderProblems(problems) {
let triggers = _.cloneDeep(problems);
triggers = _.map(triggers, this.formatTrigger.bind(this));
triggers = this.filterProblems(triggers);
triggers = this.sortTriggers(triggers);
this.renderData = triggers;
return this.$timeout(() => {
return super.render(triggers);
});
}
filterProblems(problems) {
let problemsList = _.cloneDeep(problems);
// Filter acknowledged triggers
if (this.panel.showTriggers === 'unacknowledged') {
problemsList = _.filter(problemsList, trigger => {
return !(trigger.acknowledges && trigger.acknowledges.length);
});
} else if (this.panel.showTriggers === 'acknowledged') {
problemsList = _.filter(problemsList, trigger => {
return trigger.acknowledges && trigger.acknowledges.length;
});
}
// Filter triggers by severity
problemsList = _.filter(problemsList, problem => {
if (problem.severity) {
return this.panel.triggerSeverity[problem.severity].show;
} else {
return this.panel.triggerSeverity[problem.priority].show;
}
});
return problemsList;
}
sortTriggers(triggerList) {
if (this.panel.sortProblems === 'priority') {
triggerList = _.orderBy(triggerList, ['priority', 'lastchangeUnix', 'triggerid'], ['desc', 'desc', 'desc']);
} else if (this.panel.sortProblems === 'lastchange') {
triggerList = _.orderBy(triggerList, ['lastchangeUnix', 'priority', 'triggerid'], ['desc', 'desc', 'desc']);
}
return triggerList;
}
formatTrigger(zabbixTrigger) {
const trigger = _.cloneDeep(zabbixTrigger);
// Set host and proxy that the trigger belongs
if (trigger.hosts && trigger.hosts.length) {
const host = trigger.hosts[0];
trigger.host = host.name;
trigger.hostTechName = host.host;
if (host.proxy) {
trigger.proxy = host.proxy;
}
}
// Set tags if present
if (trigger.tags && trigger.tags.length === 0) {
trigger.tags = null;
}
// Handle multi-line description
if (trigger.comments) {
trigger.comments = trigger.comments.replace('\n', '<br>');
}
trigger.lastchangeUnix = Number(trigger.lastchange);
return trigger;
}
parseTags(tagStr) {
if (!tagStr) {
return [];
}
let tags = _.map(tagStr.split(','), (tag) => tag.trim());
tags = _.map(tags, (tag) => {
const tagParts = tag.split(':');
return {tag: tagParts[0].trim(), value: tagParts[1].trim()};
});
return tags;
}
tagsToString(tags) {
return _.map(tags, (tag) => `${tag.tag}:${tag.value}`).join(', ');
}
addTagFilter(tag, datasource) {
for (const target of this.panel.targets) {
if (target.datasource === datasource || this.panel.datasource === datasource) {
const tagFilter = target.tags.filter;
let targetTags = this.parseTags(tagFilter);
const newTag = {tag: tag.tag, value: tag.value};
targetTags.push(newTag);
targetTags = _.uniqWith(targetTags, _.isEqual);
const newFilter = this.tagsToString(targetTags);
target.tags.filter = newFilter;
}
}
this.refresh();
}
removeTagFilter(tag, datasource) {
const matchTag = t => t.tag === tag.tag && t.value === tag.value;
for (const target of this.panel.targets) {
if (target.datasource === datasource || this.panel.datasource === datasource) {
const tagFilter = target.tags.filter;
let targetTags = this.parseTags(tagFilter);
_.remove(targetTags, matchTag);
targetTags = _.uniqWith(targetTags, _.isEqual);
const newFilter = this.tagsToString(targetTags);
target.tags.filter = newFilter;
}
}
this.refresh();
}
getProblemEvents(problem) {
const triggerids = [problem.triggerid];
const timeFrom = Math.ceil(dateMath.parse(this.range.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(this.range.to) / 1000);
return getDataSourceSrv().get(problem.datasource)
.then((datasource: any) => {
return datasource.zabbix.getEvents(triggerids, timeFrom, timeTo, [0, 1], PROBLEM_EVENTS_LIMIT);
});
}
getProblemAlerts(problem: ProblemDTO) {
if (!problem.eventid) {
return Promise.resolve([]);
}
const eventids = [problem.eventid];
return getDataSourceSrv().get(problem.datasource)
.then((datasource: any) => {
return datasource.zabbix.getEventAlerts(eventids);
});
}
getAlertIconClassBySeverity(triggerSeverity) {
let iconClass = 'icon-gf-online';
if (triggerSeverity.priority >= 2) {
iconClass = 'icon-gf-critical';
}
return iconClass;
}
resetResizedColumns() {
this.panel.resizedColumns = [];
this.render();
}
acknowledgeProblem(problem: ProblemDTO, message, action, severity) {
const eventid = problem.eventid;
const grafana_user = this.contextSrv.user.name;
const ack_message = grafana_user + ' (Grafana): ' + message;
return getDataSourceSrv().get(problem.datasource)
.then((datasource: any) => {
const userIsEditor = this.contextSrv.isEditor || this.contextSrv.isGrafanaAdmin;
if (datasource.disableReadOnlyUsersAck && !userIsEditor) {
return Promise.reject({message: 'You have no permissions to acknowledge events.'});
}
if (eventid) {
return datasource.zabbix.acknowledgeEvent(eventid, ack_message, action, severity);
} else {
return Promise.reject({message: 'Trigger has no events. Nothing to acknowledge.'});
}
})
.then(this.refresh.bind(this))
.catch((err) => {
this.setPanelError(err);
return Promise.reject(err);
});
}
handlePageSizeChange(pageSize, pageIndex) {
this.panel.pageSize = pageSize;
this.pageIndex = pageIndex;
this.scope.$apply(() => {
this.render();
});
}
handleColumnResize(newResized) {
this.panel.resizedColumns = newResized;
this.scope.$apply(() => {
this.render();
});
}
link(scope, elem, attrs, ctrl) {
const panel = ctrl.panel;
ctrl.events.on(PanelEvents.render, (renderData) => {
renderData = renderData || this.renderData;
renderPanel(renderData);
});
function renderPanel(problems) {
const timeFrom = Math.ceil(dateMath.parse(ctrl.range.from) / 1000);
const timeTo = Math.ceil(dateMath.parse(ctrl.range.to) / 1000);
const fontSize = parseInt(panel.fontSize.slice(0, panel.fontSize.length - 1), 10);
const fontSizeProp = fontSize && fontSize !== 100 ? fontSize : null;
const pageSize = panel.pageSize || 10;
const loading = ctrl.loading && (!problems || !problems.length);
const panelOptions = {};
for (const prop in PANEL_DEFAULTS) {
panelOptions[prop] = ctrl.panel[prop];
}
const problemsListProps = {
problems,
panelOptions,
timeRange: { timeFrom, timeTo },
loading,
pageSize,
fontSize: fontSizeProp,
panelId: ctrl.panel.id,
getProblemEvents: ctrl.getProblemEvents.bind(ctrl),
getProblemAlerts: ctrl.getProblemAlerts.bind(ctrl),
onPageSizeChange: ctrl.handlePageSizeChange.bind(ctrl),
onColumnResize: ctrl.handleColumnResize.bind(ctrl),
onProblemAck: (trigger, data) => {
const { message, action, severity } = data;
return ctrl.acknowledgeProblem(trigger, message, action, severity);
},
onTagClick: (tag, datasource, ctrlKey, shiftKey) => {
if (ctrlKey || shiftKey) {
ctrl.removeTagFilter(tag, datasource);
} else {
ctrl.addTagFilter(tag, datasource);
}
}
};
let problemsReactElem;
if (panel.layout === 'list') {
problemsReactElem = React.createElement(AlertList, problemsListProps);
} else {
problemsReactElem = React.createElement(ProblemList, problemsListProps);
}
const panelContainerElem = elem.find('.panel-content');
if (panelContainerElem && panelContainerElem.length) {
ReactDOM.render(problemsReactElem, panelContainerElem[0]);
} else {
ReactDOM.render(problemsReactElem, elem[0]);
}
}
}
}
TriggerPanelCtrl.templateUrl = 'public/plugins/alexanderzobnin-zabbix-app/panel-triggers/partials/module.html';

View File

@@ -1,131 +0,0 @@
import _ from 'lodash';
import * as utils from '../datasource-zabbix/utils';
import { getDefaultTarget } from './triggers_panel_ctrl';
class TriggersTabCtrl {
/** @ngInject */
constructor($scope, $rootScope, uiSegmentSrv, templateSrv) {
$scope.editor = this;
this.panelCtrl = $scope.ctrl;
this.panel = this.panelCtrl.panel;
this.templateSrv = templateSrv;
this.datasources = {};
// Load scope defaults
var scopeDefaults = {
getGroupNames: {},
getHostNames: {},
getApplicationNames: {},
getProxyNames: {},
oldTarget: _.cloneDeep(this.panel.targets)
};
_.defaultsDeep(this, scopeDefaults);
this.selectedDatasources = this.getSelectedDatasources();
this.initDatasources();
this.panelCtrl.refresh();
}
initDatasources() {
return this.panelCtrl.initDatasources()
.then((datasources) => {
_.each(datasources, (datasource) => {
this.datasources[datasource.name] = datasource;
this.bindSuggestionFunctions(datasource);
});
});
}
bindSuggestionFunctions(datasource) {
// Map functions for bs-typeahead
let ds = datasource.name;
this.getGroupNames[ds] = _.bind(this.suggestGroups, this, datasource);
this.getHostNames[ds] = _.bind(this.suggestHosts, this, datasource);
this.getApplicationNames[ds] = _.bind(this.suggestApps, this, datasource);
this.getProxyNames[ds] = _.bind(this.suggestProxies, this, datasource);
}
getSelectedDatasources() {
return _.compact(this.panel.targets.map(target => target.datasource));
}
suggestGroups(datasource, query, callback) {
return datasource.zabbix.getAllGroups()
.then(groups => {
return _.map(groups, 'name');
})
.then(callback);
}
suggestHosts(datasource, query, callback) {
const target = this.panel.targets.find(t => t.datasource === datasource.name);
let groupFilter = datasource.replaceTemplateVars(target.group.filter);
return datasource.zabbix.getAllHosts(groupFilter)
.then(hosts => {
return _.map(hosts, 'name');
})
.then(callback);
}
suggestApps(datasource, query, callback) {
const target = this.panel.targets.find(t => t.datasource === datasource.name);
let groupFilter = datasource.replaceTemplateVars(target.group.filter);
let hostFilter = datasource.replaceTemplateVars(target.host.filter);
return datasource.zabbix.getAllApps(groupFilter, hostFilter)
.then(apps => {
return _.map(apps, 'name');
})
.then(callback);
}
suggestProxies(datasource, query, callback) {
return datasource.zabbix.getProxies()
.then(proxies => _.map(proxies, 'host'))
.then(callback);
}
datasourcesChanged() {
const newTargets = [];
_.each(this.selectedDatasources, (ds) => {
const dsTarget = this.panel.targets.find((target => target.datasource === ds));
if (dsTarget) {
newTargets.push(dsTarget);
} else {
const newTarget = getDefaultTarget(this.panel.targets);
newTarget.datasource = ds;
newTargets.push(newTarget);
}
this.panel.targets = newTargets;
});
this.parseTarget();
}
parseTarget() {
this.initDatasources()
.then(() => {
var newTarget = _.cloneDeep(this.panel.targets);
if (!_.isEqual(this.oldTarget, newTarget)) {
this.oldTarget = newTarget;
this.panelCtrl.refresh();
}
});
}
isRegex(str) {
return utils.isRegex(str);
}
isVariable(str) {
return utils.isTemplateVariable(str, this.templateSrv.variables);
}
}
export function triggerPanelTriggersTab() {
return {
restrict: 'E',
scope: true,
templateUrl: 'public/plugins/alexanderzobnin-zabbix-app/panel-triggers/partials/triggers_tab.html',
controller: TriggersTabCtrl,
};
}

View File

@@ -11,6 +11,7 @@ export interface ProblemsPanelOptions {
statusField?: boolean;
statusIcon?: boolean;
severityField?: boolean;
ackField?: boolean;
ageField?: boolean;
descriptionField?: boolean;
descriptionAtNewLine?: boolean;
@@ -140,6 +141,7 @@ export interface ZBXEvent {
object?: string;
objectid?: string;
acknowledged?: string;
severity?: string;
hosts?: ZBXHost[];
acknowledges?: ZBXAcknowledge[];
}

View File

@@ -2,12 +2,12 @@ import _ from 'lodash';
import moment from 'moment';
import { DataQuery } from '@grafana/data';
import * as utils from '../datasource-zabbix/utils';
import { ZBXTrigger } from './types';
import { ProblemDTO } from 'datasource-zabbix/types';
export function isNewProblem(problem: ZBXTrigger, highlightNewerThan: string): boolean {
export function isNewProblem(problem: ProblemDTO, highlightNewerThan: string): boolean {
try {
const highlightIntervalMs = utils.parseInterval(highlightNewerThan);
const durationSec = (Date.now() - problem.lastchangeUnix * 1000);
const durationSec = (Date.now() - problem.timestamp * 1000);
return durationSec < highlightIntervalMs;
} catch (e) {
return false;
@@ -32,3 +32,74 @@ export const getNextRefIdChar = (queries: DataQuery[]): string => {
});
});
};
export type UrlQueryMap = Record<string, any>;
export function renderUrl(path: string, query: UrlQueryMap | undefined): string {
if (query && Object.keys(query).length > 0) {
path += '?' + toUrlParams(query);
}
return path;
}
function encodeURIComponentAsAngularJS(val: string, pctEncodeSpaces?: boolean) {
return encodeURIComponent(val)
.replace(/%25/gi, '%2525') // Double-encode % symbol to make it properly decoded in Explore
.replace(/%40/gi, '@')
.replace(/%3A/gi, ':')
.replace(/%24/g, '$')
.replace(/%2C/gi, ',')
.replace(/%3B/gi, ';')
.replace(/%20/g, pctEncodeSpaces ? '%20' : '+');
}
function toUrlParams(a: any) {
const s: any[] = [];
const rbracket = /\[\]$/;
const isArray = (obj: any) => {
return Object.prototype.toString.call(obj) === '[object Array]';
};
const add = (k: string, v: any) => {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
if (typeof v !== 'boolean') {
s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
} else {
s[s.length] = encodeURIComponentAsAngularJS(k, true);
}
};
const buildParams = (prefix: string, obj: any) => {
let i, len, key;
if (prefix) {
if (isArray(obj)) {
for (i = 0, len = obj.length; i < len; i++) {
if (rbracket.test(prefix)) {
add(prefix, obj[i]);
} else {
buildParams(prefix, obj[i]);
}
}
} else if (obj && String(obj) === '[object Object]') {
for (key in obj) {
buildParams(prefix + '[' + key + ']', obj[key]);
}
} else {
add(prefix, obj);
}
} else if (isArray(obj)) {
for (i = 0, len = obj.length; i < len; i++) {
add(obj[i].name, obj[i].value);
}
} else {
for (key in obj) {
buildParams(key, obj[key]);
}
}
return s;
};
return buildParams('', a).join('&');
}