Migrate problems panel to React (#1532)

* Replace default angular app config editor

* Problems panel: migrate module to ts

* Problems panel options editor to react

* Problems panel react WIP

* Fix explore button

* Problems panel alert list layout WIP

* Refactor

* Minor tweaks on panel options

* remove outdated tests

* Update typescript

* Draft for tag event handling

* Remove unused files
This commit is contained in:
Alexander Zobnin
2022-11-30 14:01:21 +03:00
committed by GitHub
parent 504c9af226
commit 9b2079c1da
35 changed files with 1188 additions and 1630 deletions

View File

@@ -1 +0,0 @@
<h3 class="page-heading">Zabbix Plugin Config</h3>

View File

@@ -1,4 +0,0 @@
export class ZabbixAppConfigCtrl {
constructor() { }
}
ZabbixAppConfigCtrl.templateUrl = 'app_config_ctrl/config.html';

View File

@@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { locationService } from '@grafana/runtime';
import { ExploreUrlState, TimeRange, urlUtil } from "@grafana/data";
import { ExploreUrlState, TimeRange, urlUtil } from '@grafana/data';
import { MODE_ITEMID, MODE_METRICS } from '../../datasource-zabbix/constants';
import { ActionButton } from '../ActionButton/ActionButton';
import { expandItemName } from '../../datasource-zabbix/utils';
@@ -35,7 +35,7 @@ const openInExplore = (problem: ProblemDTO, panelId: number, range: TimeRange) =
item: { filter: itemName },
};
} else {
const itemids = problem.items?.map(p => p.itemid).join(',');
const itemids = problem.items?.map((p) => p.itemid).join(',');
query = {
queryType: MODE_ITEMID,
itemids: itemids,
@@ -54,4 +54,3 @@ const openInExplore = (problem: ProblemDTO, panelId: number, range: TimeRange) =
const url = urlUtil.renderUrl('/explore', { left: exploreState });
locationService.push(url);
};

View File

@@ -1,4 +1,4 @@
import { DataQuery, DataSourceJsonData, DataSourceRef, SelectableValue } from '@grafana/data';
import { BusEventWithPayload, DataQuery, DataSourceJsonData, DataSourceRef, SelectableValue } from '@grafana/data';
export interface ZabbixDSOptions extends DataSourceJsonData {
username: string;
@@ -207,6 +207,8 @@ export interface ProblemDTO {
triggerid?: string;
eventid?: string;
timestamp: number;
lastchange?: string;
lastchangeUnix?: number;
/** Name of the trigger. */
name?: string;
@@ -223,6 +225,7 @@ export interface ProblemDTO {
hostTechName?: string;
proxy?: string;
severity?: string;
priority?: string;
acknowledged?: '1' | '0';
acknowledges?: ZBXAcknowledge[];
@@ -245,6 +248,7 @@ export interface ProblemDTO {
error?: string;
showAckButton?: boolean;
type?: string;
}
export interface ZBXProblem {
@@ -323,6 +327,7 @@ export interface ZBXHost {
host: string;
maintenance_status?: string;
proxy_hostid?: string;
proxy?: any;
}
export interface ZBXItem {
@@ -380,3 +385,7 @@ export interface ZBXAlert {
message: string;
error: string;
}
export class ZBXQueryUpdatedEvent extends BusEventWithPayload<any> {
static type = 'zbx-query-updated';
}

View File

@@ -1,14 +1,12 @@
import { AppPlugin } from '@grafana/data';
import { loadPluginCss } from 'grafana/app/plugins/sdk';
import './sass/grafana-zabbix.dark.scss';
import './sass/grafana-zabbix.light.scss';
import {ZabbixAppConfigCtrl} from './app_config_ctrl/config';
import {loadPluginCss} from 'grafana/app/plugins/sdk';
loadPluginCss({
dark: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.dark.css',
light: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.light.css'
light: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.light.css',
});
export {
ZabbixAppConfigCtrl as ConfigCtrl
};
export const plugin = new AppPlugin<{}>();

View File

@@ -0,0 +1,277 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import _ from 'lodash';
import { BusEventBase, BusEventWithPayload, dateMath, PanelProps } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import { contextSrv } from 'grafana/app/core/core';
import { ProblemsPanelOptions } from './types';
import { ProblemDTO, ZabbixMetricsQuery, ZBXQueryUpdatedEvent, ZBXTag } from '../datasource-zabbix/types';
import { APIExecuteScriptResponse } from '../datasource-zabbix/zabbix/connectors/zabbix_api/types';
import ProblemList from './components/Problems/Problems';
import { AckProblemData } from './components/AckModal';
import AlertList from './components/AlertList/AlertList';
const PROBLEM_EVENTS_LIMIT = 100;
interface ProblemsPanelProps extends PanelProps<ProblemsPanelOptions> {}
export const ProblemsPanel = (props: ProblemsPanelProps): JSX.Element => {
const { data, options, timeRange, onOptionsChange } = props;
const { layout, showTriggers, triggerSeverity, sortProblems } = options;
const theme = useTheme2();
const prepareProblems = () => {
const problems: ProblemDTO[] = [];
if (!data?.series) {
return [];
}
for (const dataFrame of data.series) {
try {
const values = dataFrame.fields[0].values;
if (values.toArray) {
problems.push(...values.toArray());
}
} catch (error) {
console.log(error);
return [];
}
}
let triggers = _.cloneDeep(problems);
triggers = triggers.map((t) => formatTrigger(t));
triggers = filterProblems(triggers);
triggers = sortTriggers(triggers);
return triggers;
};
const filterProblems = (problems: ProblemDTO[]) => {
let problemsList = _.cloneDeep(problems);
// Filter acknowledged triggers
if (showTriggers === 'unacknowledged') {
problemsList = problemsList.filter((trigger) => {
return !(trigger.acknowledges && trigger.acknowledges.length);
});
} else if (showTriggers === 'acknowledged') {
problemsList = problemsList.filter((trigger) => {
return trigger.acknowledges && trigger.acknowledges.length;
});
}
// Filter triggers by severity
problemsList = problemsList.filter((problem) => {
if (problem.severity) {
return triggerSeverity[problem.severity].show;
} else {
return triggerSeverity[problem.priority].show;
}
});
return problemsList;
};
const sortTriggers = (problems: ProblemDTO[]) => {
if (sortProblems === 'priority') {
problems = _.orderBy(problems, ['severity', 'timestamp', 'eventid'], ['desc', 'desc', 'desc']);
} else if (sortProblems === 'lastchange') {
problems = _.orderBy(problems, ['timestamp', 'severity', 'eventid'], ['desc', 'desc', 'desc']);
}
return problems;
};
const formatTrigger = (zabbixTrigger: ProblemDTO) => {
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;
};
const parseTags = (tagStr: string) => {
if (!tagStr) {
return [];
}
const tagStrings = _.map(tagStr.split(','), (tag) => tag.trim());
const tags = _.map(tagStrings, (tag) => {
const tagParts = tag.split(':');
return { tag: tagParts[0].trim(), value: tagParts[1].trim() };
});
return tags;
};
const tagsToString = (tags: ZBXTag[]) => {
return _.map(tags, (tag) => `${tag.tag}:${tag.value}`).join(', ');
};
const addTagFilter = (tag, datasource) => {
const targets = data.request.targets;
let updated = false;
for (const target of targets) {
if (target.datasource?.uid === datasource?.uid || target.datasource === datasource) {
const tagFilter = (target as ZabbixMetricsQuery).tags.filter;
let targetTags = parseTags(tagFilter);
const newTag = { tag: tag.tag, value: tag.value };
targetTags.push(newTag);
targetTags = _.uniqWith(targetTags, _.isEqual);
const newFilter = tagsToString(targetTags);
(target as ZabbixMetricsQuery).tags.filter = newFilter;
updated = true;
}
}
if (updated) {
// TODO: investigate is it possible to handle this event
const event = new ZBXQueryUpdatedEvent(targets);
props.eventBus.publish(event);
}
};
const removeTagFilter = (tag, datasource) => {
const matchTag = (t) => t.tag === tag.tag && t.value === tag.value;
const targets = data.request.targets;
let updated = false;
for (const target of targets) {
if (target.datasource?.uid === datasource?.uid || target.datasource === datasource) {
const tagFilter = (target as ZabbixMetricsQuery).tags.filter;
let targetTags = parseTags(tagFilter);
_.remove(targetTags, matchTag);
targetTags = _.uniqWith(targetTags, _.isEqual);
const newFilter = tagsToString(targetTags);
(target as ZabbixMetricsQuery).tags.filter = newFilter;
updated = true;
}
}
if (updated) {
// TODO: investigate is it possible to handle this event
const event = new ZBXQueryUpdatedEvent(targets);
props.eventBus.publish(event);
}
};
const getProblemEvents = async (problem: ProblemDTO) => {
const triggerids = [problem.triggerid];
const timeFrom = Math.ceil(dateMath.parse(timeRange.from).unix());
const timeTo = Math.ceil(dateMath.parse(timeRange.to).unix());
const ds: any = await getDataSourceSrv().get(problem.datasource);
return ds.zabbix.getEvents(triggerids, timeFrom, timeTo, [0, 1], PROBLEM_EVENTS_LIMIT);
};
const getProblemAlerts = async (problem: ProblemDTO) => {
if (!problem.eventid) {
return Promise.resolve([]);
}
const eventids = [problem.eventid];
const ds: any = await getDataSourceSrv().get(problem.datasource);
return ds.zabbix.getEventAlerts(eventids);
};
const getScripts = async (problem: ProblemDTO) => {
const hostid = problem.hosts?.length ? problem.hosts[0].hostid : null;
const ds: any = await getDataSourceSrv().get(problem.datasource);
return ds.zabbix.getScripts([hostid]);
};
const onExecuteScript = async (problem: ProblemDTO, scriptid: string): Promise<APIExecuteScriptResponse> => {
const hostid = problem.hosts?.length ? problem.hosts[0].hostid : null;
const ds: any = await getDataSourceSrv().get(problem.datasource);
return ds.zabbix.executeScript(hostid, scriptid);
};
const onProblemAck = async (problem: ProblemDTO, data: AckProblemData) => {
const { message, action, severity } = data;
const eventid = problem.eventid;
const grafana_user = (contextSrv.user as any).name;
const ack_message = grafana_user + ' (Grafana): ' + message;
const ds: any = await getDataSourceSrv().get(problem.datasource);
const userIsEditor = contextSrv.isEditor || contextSrv.isGrafanaAdmin;
if (ds.disableReadOnlyUsersAck && !userIsEditor) {
return { message: 'You have no permissions to acknowledge events.' };
}
if (eventid) {
return ds.zabbix.acknowledgeEvent(eventid, ack_message, action, severity);
} else {
return { message: 'Trigger has no events. Nothing to acknowledge.' };
}
};
const onColumnResize = (newResized) => {
onOptionsChange({ ...options, resizedColumns: newResized });
};
const onTagClick = (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => {
if (ctrlKey || shiftKey) {
removeTagFilter(tag, datasource);
} else {
addTagFilter(tag, datasource);
}
};
const renderList = () => {
const problems = prepareProblems();
const fontSize = parseInt(options.fontSize.slice(0, options.fontSize.length - 1), 10);
const fontSizeProp = fontSize && fontSize !== 100 ? fontSize : null;
return (
<AlertList
problems={problems}
panelOptions={options}
pageSize={options.pageSize}
fontSize={fontSizeProp}
onProblemAck={onProblemAck}
onTagClick={onTagClick}
/>
);
};
const renderTable = () => {
const problems = prepareProblems();
const fontSize = parseInt(options.fontSize.slice(0, options.fontSize.length - 1), 10);
const fontSizeProp = fontSize && fontSize !== 100 ? fontSize : null;
return (
<ProblemList
problems={problems}
panelOptions={options}
pageSize={options.pageSize}
fontSize={fontSizeProp}
timeRange={timeRange}
getProblemEvents={getProblemEvents}
getProblemAlerts={getProblemAlerts}
getScripts={getScripts}
onExecuteScript={onExecuteScript}
onProblemAck={onProblemAck}
onColumnResize={onColumnResize}
onTagClick={onTagClick}
/>
);
};
return (
<>
{layout === 'list' && renderList()}
{layout === 'table' && renderTable()}
</>
);
};

View File

@@ -1,27 +1,29 @@
import React, { PureComponent } from 'react';
import { ZBXTrigger } from '../../types';
import { ProblemDTO } from '../../../datasource-zabbix/types';
interface AlertAcknowledgesProps {
problem: ZBXTrigger;
problem: ProblemDTO;
onClick: (event?) => void;
}
export default class AlertAcknowledges extends PureComponent<AlertAcknowledgesProps> {
handleClick = (event) => {
this.props.onClick(event);
}
};
render() {
const { problem } = this.props;
const ackRows = problem.acknowledges && problem.acknowledges.map(ack => {
return (
<tr key={ack.acknowledgeid}>
<td>{ack.time}</td>
<td>{ack.user}</td>
<td>{ack.message}</td>
</tr>
);
});
const ackRows =
problem.acknowledges &&
problem.acknowledges.map((ack) => {
return (
<tr key={ack.acknowledgeid}>
<td>{ack.time}</td>
<td>{ack.user}</td>
<td>{ack.message}</td>
</tr>
);
});
return (
<div className="ack-tooltip">
<table className="table">
@@ -32,17 +34,19 @@ export default class AlertAcknowledges extends PureComponent<AlertAcknowledgesPr
<th className="ack-comments">Comments</th>
</tr>
</thead>
<tbody>
{ackRows}
</tbody>
<tbody>{ackRows}</tbody>
</table>
{problem.showAckButton &&
{problem.showAckButton && (
<div className="ack-add-button">
<button id="add-acknowledge-btn" className="btn btn-mini btn-inverse gf-form-button" onClick={this.handleClick}>
<button
id="add-acknowledge-btn"
className="btn btn-mini btn-inverse gf-form-button"
onClick={this.handleClick}
>
<i className="fa fa-plus"></i>
</button>
</div>
}
)}
</div>
);
}

View File

@@ -31,25 +31,32 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
ackProblem = (data: AckProblemData) => {
const problem = this.props.problem;
return this.props.onProblemAck(problem, data);
}
};
render() {
const { problem, panelOptions } = this.props;
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 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 problemSeverity = Number(problem.severity);
let severityDesc: TriggerSeverity;
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
severityDesc = _.find(panelOptions.triggerSeverity, (s) => s.priority === problemSeverity);
if (problem.severity) {
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
severityDesc = _.find(panelOptions.triggerSeverity, (s) => s.priority === problemSeverity);
}
const lastchange = formatLastChange(problem.timestamp, panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat);
const lastchange = formatLastChange(
problem.timestamp,
panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat
);
const age = moment.unix(problem.timestamp).fromNow(true);
let dsName: string = (problem.datasource as string);
let dsName: string = problem.datasource as string;
if ((problem.datasource as DataSourceRef)?.uid) {
const dsInstance = getDataSourceSrv().getInstanceSettings((problem.datasource as DataSourceRef).uid);
dsName = dsInstance.name;
@@ -64,7 +71,7 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
let problemColor: string;
if (problem.value === '0') {
problemColor = panelOptions.okEventColor;
} else if (panelOptions.markAckEvents && problem.acknowledged === "1") {
} else if (panelOptions.markAckEvents && problem.acknowledged === '1') {
problemColor = panelOptions.ackEventColor;
} else {
problemColor = severityDesc.color;
@@ -77,7 +84,12 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
return (
<li className={cardClass} style={cardStyle}>
<AlertIcon problem={problem} color={problemColor} highlightBackground={panelOptions.highlightBackground} blink={blink} />
<AlertIcon
problem={problem}
color={problemColor}
highlightBackground={panelOptions.highlightBackground}
blink={blink}
/>
<div className="alert-rule-item__body">
<div className="alert-rule-item__header">
@@ -90,15 +102,16 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
{panelOptions.showTags && (
<span className="zbx-trigger-tags">
{problem.tags && problem.tags.map(tag =>
<EventTag
key={tag.tag + tag.value}
tag={tag}
datasource={dsName}
highlight={tag.tag === problem.correlation_tag}
onClick={this.handleTagClick}
/>
)}
{problem.tags &&
problem.tags.map((tag) => (
<EventTag
key={tag.tag + tag.value}
tag={tag}
datasource={dsName}
highlight={tag.tag === problem.correlation_tag}
onClick={this.handleTagClick}
/>
))}
</span>
)}
</div>
@@ -106,25 +119,26 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
<div className={descriptionClass}>
{panelOptions.statusField && <AlertStatus problem={problem} blink={blink} />}
{panelOptions.severityField && (
<AlertSeverity severityDesc={severityDesc} blink={blink} highlightBackground={panelOptions.highlightBackground} />
<AlertSeverity
severityDesc={severityDesc}
blink={blink}
highlightBackground={panelOptions.highlightBackground}
/>
)}
<span className="alert-rule-item__time">
{panelOptions.ageField && "for " + age}
</span>
<span className="alert-rule-item__time">{panelOptions.ageField && 'for ' + age}</span>
{panelOptions.descriptionField && !panelOptions.descriptionAtNewLine && (
<span className="zbx-description" dangerouslySetInnerHTML={{ __html: problem.comments }} />
)}
</div>
{panelOptions.descriptionField && panelOptions.descriptionAtNewLine && (
<div className="alert-rule-item__text zbx-description--newline" >
<div className="alert-rule-item__text zbx-description--newline">
<span
className="alert-rule-item__info zbx-description"
dangerouslySetInnerHTML={{ __html: problem.comments }}
/>
</div>
)}
</div>
</div>
@@ -138,30 +152,36 @@ export default class AlertCard extends PureComponent<AlertCardProps> {
)}
<div className="alert-rule-item__time zbx-trigger-lastchange">
<span>{lastchange || "last change unknown"}</span>
<span>{lastchange || 'last change unknown'}</span>
<div className="trigger-info-block zbx-status-icons">
{problem.url && <a href={problem.url} target="_blank"><i className="fa fa-external-link"></i></a>}
{problem.url && (
<a href={problem.url} target="_blank">
<i className="fa fa-external-link"></i>
</a>
)}
{problem.state === '1' && (
<Tooltip placement="bottom" content={problem.error}>
<span><i className="fa fa-question-circle"></i></span>
<span>
<i className="fa fa-question-circle"></i>
</span>
</Tooltip>
)}
{problem.eventid && (
<ModalController>
{({ showModal, hideModal }) => (
<AlertAcknowledgesButton
problem={problem}
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
{({ showModal, hideModal }) => (
<AlertAcknowledgesButton
problem={problem}
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
)}
</div>
</div>
@@ -178,7 +198,7 @@ interface AlertHostProps {
function AlertHost(props: AlertHostProps) {
const problem = props.problem;
const panel = props.panelOptions;
let host = "";
let host = '';
if (panel.hostField && panel.hostTechNameField) {
host = `${problem.host} (${problem.hostTechName})`;
} else if (panel.hostField || panel.hostTechNameField) {
@@ -204,15 +224,13 @@ interface AlertGroupProps {
function AlertGroup(props: AlertGroupProps) {
const problem = props.problem;
const panel = props.panelOptions;
let groupNames = "";
let groupNames = '';
if (panel.hostGroups) {
const groups = _.map(problem.groups, 'name').join(', ');
groupNames += `[ ${groups} ]`;
}
return (
<span className="zabbix-hostname">{groupNames}</span>
);
return <span className="zabbix-hostname">{groupNames}</span>;
}
const DEFAULT_OK_COLOR = 'rgb(56, 189, 113)';
@@ -228,11 +246,7 @@ function AlertStatus(props) {
{ 'alert-state-ok': problem.value === '0' },
{ 'zabbix-trigger--blinked': blink }
);
return (
<span className={className}>
{status}
</span>
);
return <span className={className}>{status}</span>;
}
function AlertSeverity(props) {
@@ -257,25 +271,29 @@ interface AlertAcknowledgesButtonProps {
class AlertAcknowledgesButton extends PureComponent<AlertAcknowledgesButtonProps> {
handleClick = (event) => {
this.props.onClick(event);
}
};
renderTooltipContent = () => {
return <AlertAcknowledges problem={this.props.problem} onClick={this.handleClick} />;
}
};
render() {
const { problem } = this.props;
let content = null;
if (problem.acknowledges && problem.acknowledges.length) {
content = (
<Tooltip placement="bottom" content={this.renderTooltipContent}>
<span><i className="fa fa-comments"></i></span>
<Tooltip placement="auto" content={this.renderTooltipContent} interactive>
<span>
<i className="fa fa-comments"></i>
</span>
</Tooltip>
);
} else if (problem.showAckButton) {
content = (
<Tooltip placement="bottom" content="Acknowledge problem">
<span role="button" onClick={this.handleClick}><i className="fa fa-comments-o"></i></span>
<span role="button" onClick={this.handleClick}>
<i className="fa fa-comments-o"></i>
</span>
</Tooltip>
);
}

View File

@@ -14,14 +14,14 @@ export const AlertIcon: FC<Props> = ({ problem, color, blink, highlightBackgroun
const severity = Number(problem.severity);
const status = problem.value === '1' && severity >= 2 ? 'critical' : 'online';
const iconClass = cx(
'icon-gf',
blink && 'zabbix-trigger--blinked',
);
const iconClass = cx('icon-gf', blink && 'zabbix-trigger--blinked');
const wrapperClass = cx(
'alert-rule-item__icon',
!highlightBackground && css`color: ${color}`
!highlightBackground &&
css`
color: ${color};
`
);
return (

View File

@@ -1,6 +1,6 @@
import React, { PureComponent, CSSProperties } from 'react';
import classNames from 'classnames';
import { ProblemsPanelOptions, GFTimeRange } from '../../types';
import { ProblemsPanelOptions } from '../../types';
import { AckProblemData } from '../AckModal';
import AlertCard from './AlertCard';
import { ProblemDTO, ZBXTag } from '../../../datasource-zabbix/types';
@@ -10,7 +10,6 @@ export interface AlertListProps {
problems: ProblemDTO[];
panelOptions: ProblemsPanelOptions;
loading?: boolean;
timeRange?: GFTimeRange;
pageSize?: number;
fontSize?: number;
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => void;
@@ -44,18 +43,17 @@ export default class AlertList extends PureComponent<AlertListProps, AlertListSt
page: newPage,
currentProblems: items,
});
}
};
handleTagClick = (tag: ZBXTag, datasource: string, ctrlKey?: boolean, shiftKey?: boolean) => {
if (this.props.onTagClick) {
this.props.onTagClick(tag, datasource, ctrlKey, shiftKey);
}
}
};
handleProblemAck = (problem: ProblemDTO, data: AckProblemData) => {
return this.props.onProblemAck(problem, data);
}
};
render() {
const { problems, panelOptions } = this.props;
@@ -68,15 +66,17 @@ export default class AlertList extends PureComponent<AlertListProps, AlertListSt
<div className="triggers-panel-container" key="alertListContainer">
<section className="card-section card-list-layout-list">
<ol className={alertListClass}>
{currentProblems.map((problem, index) =>
{currentProblems.map((problem, index) => (
<AlertCard
key={`${problem.triggerid}-${problem.eventid}-${(problem.datasource as DataSourceRef)?.uid || problem.datasource}-${index}`}
key={`${problem.triggerid}-${problem.eventid}-${
(problem.datasource as DataSourceRef)?.uid || problem.datasource
}-${index}`}
problem={problem}
panelOptions={panelOptions}
onTagClick={this.handleTagClick}
onProblemAck={this.handleProblemAck}
/>
)}
))}
</ol>
</section>
@@ -101,10 +101,9 @@ interface PaginationControlProps {
}
class PaginationControl extends PureComponent<PaginationControlProps> {
handlePageChange = (index: number) => () => {
this.props.onPageChange(index);
}
};
render() {
const { itemsLength, pageIndex, pageSize } = this.props;
@@ -116,23 +115,20 @@ class PaginationControl extends PureComponent<PaginationControlProps> {
const startPage = Math.max(pageIndex - 3, 0);
const endPage = Math.min(pageCount, startPage + 9);
const pageLinks = [];
for (let i = startPage; i < endPage; i++) {
const pageLinkClass = classNames('triggers-panel-page-link', 'pointer', { 'active': i === pageIndex });
const pageLinkClass = classNames('triggers-panel-page-link', 'pointer', { active: i === pageIndex });
const value = i + 1;
const pageLinkElem = (
<li key={value.toString()}>
<a className={pageLinkClass} onClick={this.handlePageChange(i)}>{value}</a>
<a className={pageLinkClass} onClick={this.handlePageChange(i)}>
{value}
</a>
</li>
);
pageLinks.push(pageLinkElem);
}
return (
<ul>
{pageLinks}
</ul>
);
return <ul>{pageLinks}</ul>;
}
}

View File

@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { ZBXTag } from '../types';
import { DataSourceRef } from '@grafana/data';
import { Tooltip } from '@grafana/ui';
import { ZBXTag } from '../../datasource-zabbix/types';
const TAG_COLORS = [
'#E24D42',
@@ -96,10 +96,10 @@ interface EventTagProps {
export default class EventTag extends PureComponent<EventTagProps> {
handleClick = (event) => {
if (this.props.onClick) {
const { tag, datasource} = this.props;
const { tag, datasource } = this.props;
this.props.onClick(tag, datasource, event.ctrlKey, event.shiftKey);
}
}
};
render() {
const { tag, highlight } = this.props;
@@ -110,13 +110,12 @@ export default class EventTag extends PureComponent<EventTagProps> {
};
return (
<Tooltip placement="bottom" content="Click to add tag filter or Ctrl/Shift+click to remove">
<span className={`label label-tag zbx-tag ${highlight ? 'highlighted' : ''}`}
<span
className={`label label-tag zbx-tag ${highlight ? 'highlighted' : ''}`}
style={style}
onClick={this.handleClick}>
{tag.value ?
`${tag.tag}: ${tag.value}` :
`${tag.tag}`
}
onClick={this.handleClick}
>
{tag.value ? `${tag.tag}: ${tag.value}` : `${tag.tag}`}
</span>
</Tooltip>
);

View File

@@ -46,7 +46,7 @@ export class ExecScriptModalUnthemed extends PureComponent<Props, State> {
async componentDidMount() {
const scripts = await this.props.getScripts();
this.scripts = scripts;
const scriptOptions: Array<SelectableValue<string>> = scripts.map(s => {
const scriptOptions: Array<SelectableValue<string>> = scripts.map((s) => {
return {
value: s.scriptid,
label: s.name,
@@ -55,27 +55,27 @@ export class ExecScriptModalUnthemed extends PureComponent<Props, State> {
});
const selectedScript = scriptOptions?.length ? scriptOptions[0] : null;
const script = scripts.find(s => selectedScript.value === s.scriptid);
const script = scripts.find((s) => selectedScript.value === s.scriptid);
this.setState({ scriptOptions, selectedScript, script });
}
onChangeSelectedScript = (v: SelectableValue<string>) => {
const script = this.scripts.find(s => v.value === s.scriptid);
const script = this.scripts.find((s) => v.value === s.scriptid);
this.setState({ selectedScript: v, script, errorMessage: '', loading: false, result: '' });
};
dismiss = () => {
this.setState({ selectedScript: null, error: false, errorMessage: '', selectError: '', loading: false });
this.props.onDismiss();
}
};
submit = () => {
const { selectedScript } = this.state;
if (!selectedScript) {
return this.setState({
selectError: 'Select a script to execute.'
selectError: 'Select a script to execute.',
});
}
@@ -85,30 +85,33 @@ export class ExecScriptModalUnthemed extends PureComponent<Props, State> {
scriptid: selectedScript.value,
};
this.props.onSubmit(data).then((result: APIExecuteScriptResponse) => {
const message = this.formatResult(result?.value || '');
if (result?.response === 'success') {
this.setState({ result: message, loading: false });
} else {
this.setState({ error: true, errorMessage: message, loading: false });
}
}).catch(err => {
let errorMessage = err.data?.message || err.data?.error || err.data || err.statusText || '';
errorMessage = this.formatResult(errorMessage);
this.setState({
error: true,
loading: false,
errorMessage,
this.props
.onSubmit(data)
.then((result: APIExecuteScriptResponse) => {
const message = this.formatResult(result?.value || '');
if (result?.response === 'success') {
this.setState({ result: message, loading: false });
} else {
this.setState({ error: true, errorMessage: message, loading: false });
}
})
.catch((err) => {
let errorMessage = err.data?.message || err.data?.error || err.data || err.statusText || '';
errorMessage = this.formatResult(errorMessage);
this.setState({
error: true,
loading: false,
errorMessage,
});
});
});
}
};
formatResult = (result: string) => {
const formatted = result.split('\n').map((p, i) => {
return <p key={i}>{p}</p>;
});
return <>{formatted}</>;
}
};
render() {
const { theme } = this.props;
@@ -135,14 +138,8 @@ export class ExecScriptModalUnthemed extends PureComponent<Props, State> {
>
<div className="gf-form">
<label className="gf-form-hint">
<Select
options={scriptOptions}
value={selectedScript}
onChange={this.onChangeSelectedScript}
/>
{selectError &&
<small className={selectErrorClass}>{selectError}</small>
}
<Select options={scriptOptions} value={selectedScript} onChange={this.onChangeSelectedScript} />
{selectError && <small className={selectErrorClass}>{selectError}</small>}
</label>
</div>
<div className={scriptCommandContainerClass}>
@@ -150,17 +147,17 @@ export class ExecScriptModalUnthemed extends PureComponent<Props, State> {
</div>
<div className={styles.resultContainer}>
{result &&
<span className={styles.execResult}>{result}</span>
}
{error &&
<span className={styles.execError}>{errorMessage}</span>
}
{result && <span className={styles.execResult}>{result}</span>}
{error && <span className={styles.execError}>{errorMessage}</span>}
</div>
<div className="gf-form-button-row text-center">
<Button variant="primary" onClick={this.submit}>Execute</Button>
<Button variant="secondary" onClick={this.dismiss}>Cancel</Button>
<Button variant="primary" onClick={this.submit}>
Execute
</Button>
<Button variant="secondary" onClick={this.dismiss}>
Cancel
</Button>
</div>
</Modal>
);
@@ -211,8 +208,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-bottom: 0px;
}
`,
execResult: css`
`,
execResult: css``,
execError: css`
color: ${red};
`,

View File

@@ -0,0 +1,69 @@
import React, { FormEvent } from 'react';
import {
Button,
ColorPicker,
HorizontalGroup,
InlineField,
InlineFieldRow,
InlineLabel,
InlineSwitch,
Input,
VerticalGroup,
} from '@grafana/ui';
import { StandardEditorProps } from '@grafana/data';
import { GFHeartIcon } from '../../components';
import { TriggerSeverity } from '../types';
type Props = StandardEditorProps<TriggerSeverity[]>;
export const ProblemColorEditor = ({ value, onChange }: Props): JSX.Element => {
const onSeverityItemChange = (severity: TriggerSeverity) => {
value.forEach((v, i) => {
if (v.priority === severity.priority) {
value[i] = severity;
}
});
onChange(value);
};
return (
<>
{value.map((severity, index) => (
<ProblemColorEditorRow
key={`${severity.priority}-${index}`}
value={severity}
onChange={(value) => onSeverityItemChange(value)}
/>
))}
</>
);
};
interface ProblemColorEditorRowProps {
value: TriggerSeverity;
onChange: (value?: TriggerSeverity) => void;
}
export const ProblemColorEditorRow = ({ value, onChange }: ProblemColorEditorRowProps): JSX.Element => {
const onSeverityNameChange = (v: FormEvent<HTMLInputElement>) => {
const newValue = v?.currentTarget?.value;
if (newValue !== null) {
onChange({ ...value, severity: newValue });
}
};
return (
<VerticalGroup>
<InlineFieldRow>
<InlineField labelWidth={12}>
<Input width={24} defaultValue={value.severity} onBlur={onSeverityNameChange} />
</InlineField>
<InlineLabel width={4}>
<ColorPicker color={value.color} onChange={(color) => onChange({ ...value, color })} />
</InlineLabel>
<InlineField label="Show">
<InlineSwitch value={value.show} onChange={() => onChange({ ...value, show: !value.show })} />
</InlineField>
</InlineFieldRow>
</VerticalGroup>
);
};

View File

@@ -21,12 +21,12 @@ export const AckCell: React.FC<RTCell<ProblemDTO>> = (props: RTCell<ProblemDTO>)
return (
<div>
{problem.acknowledges?.length > 0 &&
{problem.acknowledges?.length > 0 && (
<>
<FAIcon icon="comments" />
<span className={styles.countLabel}> ({problem.acknowledges?.length})</span>
</>
}
)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { ZBXAcknowledge } from '../../types';
import { ZBXAcknowledge } from '../../../datasource-zabbix/types';
interface AcknowledgesListProps {
acknowledges: ZBXAcknowledge[];
@@ -10,13 +10,25 @@ export default function AcknowledgesList(props: AcknowledgesListProps) {
return (
<div className="problem-ack-list">
<div className="problem-ack-col problem-ack-time">
{acknowledges.map(ack => <span key={ack.acknowledgeid} className="problem-ack-time">{ack.time}</span>)}
{acknowledges.map((ack) => (
<span key={ack.acknowledgeid} className="problem-ack-time">
{ack.time}
</span>
))}
</div>
<div className="problem-ack-col problem-ack-user">
{acknowledges.map(ack => <span key={ack.acknowledgeid} className="problem-ack-user">{ack.user}</span>)}
{acknowledges.map((ack) => (
<span key={ack.acknowledgeid} className="problem-ack-user">
{ack.user}
</span>
))}
</div>
<div className="problem-ack-col problem-ack-message">
{acknowledges.map(ack => <span key={ack.acknowledgeid} className="problem-ack-message">{ack.message}</span>)}
{acknowledges.map((ack) => (
<span key={ack.acknowledgeid} className="problem-ack-message">
{ack.message}
</span>
))}
</div>
</div>
);

View File

@@ -1,24 +1,24 @@
import React, { FC, PureComponent } from 'react';
import moment from 'moment';
import { TimeRange, DataSourceRef } from "@grafana/data";
import { TimeRange, DataSourceRef } from '@grafana/data';
import { Tooltip } from '@grafana/ui';
import { getDataSourceSrv } from '@grafana/runtime';
import * as utils from '../../../datasource-zabbix/utils';
import { ProblemDTO, ZBXAlert, ZBXEvent, ZBXGroup, ZBXHost, ZBXTag } from '../../../datasource-zabbix/types';
import { APIExecuteScriptResponse, ZBXScript } from '../../../datasource-zabbix/zabbix/connectors/zabbix_api/types';
import { GFTimeRange, RTRow, ZBXItem } from '../../types';
import { AckModal, AckProblemData } from '../AckModal';
import EventTag from '../EventTag';
import AcknowledgesList from './AcknowledgesList';
import ProblemTimeline from './ProblemTimeline';
import { AckButton, ExecScriptButton, ExploreButton, FAIcon, ModalController } from '../../../components';
import { ExecScriptData, ExecScriptModal } from '../ExecScriptModal';
import ProblemStatusBar from "./ProblemStatusBar";
import ProblemStatusBar from './ProblemStatusBar';
import { ZBXItem } from '../../../datasource-zabbix/types';
import { RTRow } from '../../types';
interface ProblemDetailsProps extends RTRow<ProblemDTO> {
rootWidth: number;
timeRange: GFTimeRange;
range: TimeRange;
timeRange: TimeRange;
showTimeline?: boolean;
panelId?: number;
getProblemEvents: (problem: ProblemDTO) => Promise<ZBXEvent[]>;
@@ -65,16 +65,14 @@ export class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDe
fetchProblemEvents() {
const problem = this.props.original;
this.props.getProblemEvents(problem)
.then(events => {
this.props.getProblemEvents(problem).then((events) => {
this.setState({ events });
});
}
fetchProblemAlerts() {
const problem = this.props.original;
this.props.getProblemAlerts(problem)
.then(alerts => {
this.props.getProblemAlerts(problem).then((alerts) => {
this.setState({ alerts });
});
}
@@ -97,15 +95,15 @@ export class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDe
render() {
const problem = this.props.original as ProblemDTO;
const alerts = this.state.alerts;
const { rootWidth, panelId, range } = this.props;
const { rootWidth, panelId, timeRange } = this.props;
const displayClass = this.state.show ? 'show' : '';
const wideLayout = rootWidth > 1200;
const compactStatusBar = rootWidth < 800 || problem.acknowledges && wideLayout && rootWidth < 1400;
const compactStatusBar = rootWidth < 800 || (problem.acknowledges && wideLayout && rootWidth < 1400);
const age = moment.unix(problem.timestamp).fromNow(true);
const showAcknowledges = problem.acknowledges && problem.acknowledges.length !== 0;
const problemSeverity = Number(problem.severity);
let dsName: string = (this.props.original.datasource as string);
let dsName: string = this.props.original.datasource as string;
if ((this.props.original.datasource as DataSourceRef)?.uid) {
const dsInstance = getDataSourceSrv().getInstanceSettings((this.props.original.datasource as DataSourceRef).uid);
dsName = dsInstance.name;
@@ -117,106 +115,111 @@ export class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDe
<div className="problem-details">
<div className="problem-details-head">
<div className="problem-actions-left">
<ExploreButton problem={problem} panelId={panelId} range={range}/>
<ExploreButton problem={problem} panelId={panelId} range={timeRange} />
</div>
{problem.showAckButton &&
<div className="problem-actions">
<ModalController>
{({ showModal, hideModal }) => (
<ExecScriptButton
className="problem-action-button"
onClick={() => {
showModal(ExecScriptModal, {
getScripts: this.getScripts,
onSubmit: this.onExecuteScript,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
<ModalController>
{({ showModal, hideModal }) => (
<AckButton
className="problem-action-button"
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
</div>
}
<ProblemStatusBar problem={problem} alerts={alerts} className={compactStatusBar && 'compact'}/>
{problem.showAckButton && (
<div className="problem-actions">
<ModalController>
{({ showModal, hideModal }) => (
<ExecScriptButton
className="problem-action-button"
onClick={() => {
showModal(ExecScriptModal, {
getScripts: this.getScripts,
onSubmit: this.onExecuteScript,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
<ModalController>
{({ showModal, hideModal }) => (
<AckButton
className="problem-action-button"
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
</div>
)}
<ProblemStatusBar problem={problem} alerts={alerts} className={compactStatusBar && 'compact'} />
</div>
<div className="problem-details-row">
<div className="problem-value-container">
<div className="problem-age">
<FAIcon icon="clock-o"/>
<FAIcon icon="clock-o" />
<span>{age}</span>
</div>
{problem.items && <ProblemItems items={problem.items}/>}
{problem.items && <ProblemItems items={problem.items} />}
</div>
</div>
{problem.comments &&
<div className="problem-description-row">
<div className="problem-description">
<Tooltip placement="right" content={problem.comments}>
<span className="description-label">Description:&nbsp;</span>
</Tooltip>
<span>{problem.comments}</span>
{problem.comments && (
<div className="problem-description-row">
<div className="problem-description">
<Tooltip placement="right" content={problem.comments}>
<span className="description-label">Description:&nbsp;</span>
</Tooltip>
<span>{problem.comments}</span>
</div>
</div>
)}
{problem.tags && problem.tags.length > 0 && (
<div className="problem-tags">
{problem.tags &&
problem.tags.map((tag) => (
<EventTag
key={tag.tag + tag.value}
tag={tag}
datasource={problem.datasource}
highlight={tag.tag === problem.correlation_tag}
onClick={this.handleTagClick}
/>
))}
</div>
)}
{this.props.showTimeline && this.state.events.length > 0 && (
<ProblemTimeline events={this.state.events} timeRange={this.props.timeRange} />
)}
{showAcknowledges && !wideLayout && (
<div className="problem-ack-container">
<h6>
<FAIcon icon="reply-all" /> Acknowledges
</h6>
<AcknowledgesList acknowledges={problem.acknowledges} />
</div>
)}
</div>
{showAcknowledges && wideLayout && (
<div className="problem-details-middle">
<div className="problem-ack-container">
<h6>
<FAIcon icon="reply-all" /> Acknowledges
</h6>
<AcknowledgesList acknowledges={problem.acknowledges} />
</div>
</div>
}
{problem.tags && problem.tags.length > 0 &&
<div className="problem-tags">
{problem.tags && problem.tags.map(tag =>
<EventTag
key={tag.tag + tag.value}
tag={tag}
datasource={problem.datasource}
highlight={tag.tag === problem.correlation_tag}
onClick={this.handleTagClick}
/>)
}
</div>
}
{this.props.showTimeline && this.state.events.length > 0 &&
<ProblemTimeline events={this.state.events} timeRange={this.props.timeRange}/>
}
{showAcknowledges && !wideLayout &&
<div className="problem-ack-container">
<h6><FAIcon icon="reply-all"/> Acknowledges</h6>
<AcknowledgesList acknowledges={problem.acknowledges}/>
</div>
}
</div>
{showAcknowledges && wideLayout &&
<div className="problem-details-middle">
<div className="problem-ack-container">
<h6><FAIcon icon="reply-all"/> Acknowledges</h6>
<AcknowledgesList acknowledges={problem.acknowledges}/>
</div>
</div>
}
)}
<div className="problem-details-right">
<div className="problem-details-right-item">
<FAIcon icon="database"/>
<FAIcon icon="database" />
<span>{dsName}</span>
</div>
{problem.proxy &&
<div className="problem-details-right-item">
<FAIcon icon="cloud"/>
<span>{problem.proxy}</span>
</div>
}
{problem.groups && <ProblemGroups groups={problem.groups} className="problem-details-right-item"/>}
{problem.hosts && <ProblemHosts hosts={problem.hosts} className="problem-details-right-item"/>}
{problem.proxy && (
<div className="problem-details-right-item">
<FAIcon icon="cloud" />
<span>{problem.proxy}</span>
</div>
)}
{problem.groups && <ProblemGroups groups={problem.groups} className="problem-details-right-item" />}
{problem.hosts && <ProblemHosts hosts={problem.hosts} className="problem-details-right-item" />}
</div>
</div>
</div>
@@ -232,11 +235,17 @@ interface ProblemItemProps {
function ProblemItem(props: ProblemItemProps) {
const { item, showName } = props;
const itemName = utils.expandItemName(item.name, item.key_);
const tooltipContent = () => <>{itemName}<br/>{item.lastvalue}</>;
const tooltipContent = () => (
<>
{itemName}
<br />
{item.lastvalue}
</>
);
return (
<div className="problem-item">
<FAIcon icon="thermometer-three-quarters"/>
<FAIcon icon="thermometer-three-quarters" />
{showName && <span className="problem-item-name">{item.name}: </span>}
<Tooltip placement="top-start" content={tooltipContent}>
<span className="problem-item-value">{item.lastvalue}</span>
@@ -252,10 +261,11 @@ interface ProblemItemsProps {
const ProblemItems: FC<ProblemItemsProps> = ({ items }) => {
return (
<div className="problem-items-row">
{items.length > 1 ?
items.map(item => <ProblemItem item={item} key={item.itemid} showName={true}/>) :
<ProblemItem item={items[0]}/>
}
{items.length > 1 ? (
items.map((item) => <ProblemItem item={item} key={item.itemid} showName={true} />)
) : (
<ProblemItem item={items[0]} />
)}
</div>
);
};
@@ -267,9 +277,9 @@ interface ProblemGroupsProps {
class ProblemGroups extends PureComponent<ProblemGroupsProps> {
render() {
return this.props.groups.map(g => (
return this.props.groups.map((g) => (
<div className={this.props.className || ''} key={g.groupid}>
<FAIcon icon="folder"/>
<FAIcon icon="folder" />
<span>{g.name}</span>
</div>
));
@@ -283,9 +293,9 @@ interface ProblemHostsProps {
class ProblemHosts extends PureComponent<ProblemHostsProps> {
render() {
return this.props.hosts.map(h => (
return this.props.hosts.map((h) => (
<div className={this.props.className || ''} key={h.hostid}>
<FAIcon icon="server"/>
<FAIcon icon="server" />
<span>{h.name}</span>
</div>
));

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { Tooltip } from '@grafana/ui';
import FAIcon from '../../../components/FAIcon/FAIcon';
import { ZBXTrigger, ZBXAlert } from '../../types';
import { ZBXAlert, ProblemDTO } from '../../../datasource-zabbix/types';
export interface ProblemStatusBarProps {
problem: ZBXTrigger;
problem: ProblemDTO;
alerts?: ZBXAlert[];
className?: string;
}
@@ -26,7 +26,11 @@ export default function ProblemStatusBar(props: ProblemStatusBarProps) {
<ProblemStatusBarItem icon="wrench" fired={maintenance} tooltip="Host maintenance" />
<ProblemStatusBarItem icon="globe" fired={link} link={link && problem.url} tooltip="External link" />
<ProblemStatusBarItem icon="bullhorn" fired={multiEvent} tooltip="Trigger generates multiple problem events" />
<ProblemStatusBarItem icon="tag" fired={closeByTag} tooltip={`OK event closes problems matched to tag: ${problem.correlation_tag}`} />
<ProblemStatusBarItem
icon="tag"
fired={closeByTag}
tooltip={`OK event closes problems matched to tag: ${problem.correlation_tag}`}
/>
<ProblemStatusBarItem icon="circle-o-notch" fired={actions} tooltip={actionMessage} />
<ProblemStatusBarItem icon="question-circle" fired={stateUnknown} tooltip="Current trigger state is unknown" />
<ProblemStatusBarItem icon="warning" fired={error} tooltip={problem.error} />
@@ -56,5 +60,11 @@ function ProblemStatusBarItem(props: ProblemStatusBarItemProps) {
</Tooltip>
);
}
return link ? <a href={link} target="_blank">{item}</a> : item;
return link ? (
<a href={link} target="_blank">
{item}
</a>
) : (
item
);
}

View File

@@ -1,7 +1,8 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
import moment from 'moment';
import { GFTimeRange, ZBXEvent, ZBXAcknowledge } from '../../types';
import { ZBXEvent, ZBXAcknowledge } from '../../../datasource-zabbix/types';
import { TimeRange } from '@grafana/data';
const DEFAULT_OK_COLOR = 'rgb(56, 189, 113)';
const DEFAULT_PROBLEM_COLOR = 'rgb(215, 0, 0)';
@@ -12,7 +13,7 @@ const EVENT_REGION_HEIGHT = Math.round(EVENT_POINT_SIZE * 0.6);
export interface ProblemTimelineProps {
events: ZBXEvent[];
timeRange: GFTimeRange;
timeRange: TimeRange;
okColor?: string;
problemColor?: string;
eventRegionHeight?: number;
@@ -51,7 +52,7 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
highlightedEvent: null,
highlightedRegion: null,
showEventInfo: false,
eventInfo: {}
eventInfo: {},
};
}
@@ -62,11 +63,11 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
}
}
setRootRef = ref => {
setRootRef = (ref) => {
this.rootRef = ref;
const width = ref && ref.clientWidth || 0;
const width = (ref && ref.clientWidth) || 0;
this.setState({ width });
}
};
handlePointHighlight = (index: number, secondIndex?: number) => {
const event: ZBXEvent = this.sortedEvents[index];
@@ -80,15 +81,15 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
showEventInfo: true,
highlightedRegion: regionToHighlight,
eventInfo: {
duration
}
duration,
},
});
// this.showEventInfo(event);
}
};
handlePointUnHighlight = () => {
this.setState({ showEventInfo: false, highlightedRegion: null });
}
};
handleAckHighlight = (ack: ZBXAcknowledge, index: number) => {
this.setState({
@@ -96,34 +97,34 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
eventInfo: {
timestamp: Number(ack.clock),
message: ack.message,
}
},
});
}
};
handleAckUnHighlight = () => {
this.setState({ showEventInfo: false });
}
};
showEventInfo = (event: ZBXEvent) => {
this.setState({ highlightedEvent: event, showEventInfo: true });
}
};
hideEventInfo = () => {
this.setState({ showEventInfo: false });
}
};
getRegionToHighlight = (index: number): number => {
const event = this.sortedEvents[index];
const regionToHighlight = event.value === '1' ? index + 1 : index;
return regionToHighlight;
}
};
getEventDuration(firstIndex: number, secondIndex: number): number {
return Math.abs(Number(this.sortedEvents[firstIndex].clock) - Number(this.sortedEvents[secondIndex].clock)) * 1000;
}
sortEvents() {
const events = _.sortBy(this.props.events, e => Number(e.clock));
const events = _.sortBy(this.props.events, (e) => Number(e.clock));
this.sortedEvents = events;
return events;
}
@@ -137,7 +138,7 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
}
}
}
return _.sortBy(acks, ack => Number(ack.clock));
return _.sortBy(acks, (ack) => Number(ack.clock));
}
render() {
@@ -156,13 +157,14 @@ export default class ProblemTimeline extends PureComponent<ProblemTimelineProps,
const timelineYpos = Math.round(boxHeight / 2 - eventPointSize / 2);
return (
<div className="event-timeline" ref={this.setRootRef} style={{ transform: `translate(${-padding}px, 0)`}}>
<TimelineInfoContainer className="timeline-info-container"
<div className="event-timeline" ref={this.setRootRef} style={{ transform: `translate(${-padding}px, 0)` }}>
<TimelineInfoContainer
className="timeline-info-container"
event={this.state.highlightedEvent}
eventInfo={this.state.eventInfo}
show={this.state.showEventInfo}
left={padding}
/>
/>
<svg className="event-timeline-canvas" viewBox={`0 0 ${boxWidth} ${boxHeight}`}>
<defs>
<TimelineSVGFilters />
@@ -265,7 +267,7 @@ class TimelineInfoContainer extends PureComponent<TimelineInfoContainerProps> {
<span key="ts" className="event-timestamp">
<span className="event-timestamp-label">Time:&nbsp;</span>
<span className="event-timestamp-value">{tsFormatted}</span>
</span>
</span>,
];
}
if (eventInfo && eventInfo.duration) {
@@ -285,7 +287,7 @@ class TimelineInfoContainer extends PureComponent<TimelineInfoContainerProps> {
<span key="ts" className="event-timestamp">
<span className="event-timestamp-label">Time:&nbsp;</span>
<span className="event-timestamp-value">{tsFormatted}</span>
</span>
</span>,
];
}
@@ -296,20 +298,16 @@ class TimelineInfoContainer extends PureComponent<TimelineInfoContainerProps> {
return (
<div className={className} style={containerStyle}>
<div>
{infoItems}
</div>
<div>
{durationItem}
</div>
{eventInfo && eventInfo.message &&
<div>{infoItems}</div>
<div>{durationItem}</div>
{eventInfo && eventInfo.message && (
<div>
<span key="duration" className="event-timestamp">
<span className="event-timestamp-label">Message:&nbsp;</span>
<span className="event-timestamp-value">{eventInfo.message}</span>
</span>
</div>
}
)}
</div>
);
}
@@ -317,7 +315,7 @@ class TimelineInfoContainer extends PureComponent<TimelineInfoContainerProps> {
interface TimelineRegionsProps {
events: ZBXEvent[];
timeRange: GFTimeRange;
timeRange: TimeRange;
width: number;
height: number;
okColor?: string;
@@ -332,7 +330,8 @@ class TimelineRegions extends PureComponent<TimelineRegionsProps> {
render() {
const { events, timeRange, width, height, highlightedRegion } = this.props;
const { timeFrom, timeTo } = timeRange;
const timeFrom = timeRange.from.unix();
const timeTo = timeRange.to.unix();
const range = timeTo - timeFrom;
let firstItem: React.ReactNode;
@@ -349,9 +348,7 @@ class TimelineRegions extends PureComponent<TimelineRegionsProps> {
width: regionWidth,
height: height,
};
firstItem = (
<rect key='0' className={className} {...firstEventAttributes}></rect>
);
firstItem = <rect key="0" className={className} {...firstEventAttributes}></rect>;
}
const eventsIntervalItems = events.map((event, index) => {
@@ -359,7 +356,7 @@ class TimelineRegions extends PureComponent<TimelineRegionsProps> {
const nextTs = index < events.length - 1 ? Number(events[index + 1].clock) : timeTo;
const duration = (nextTs - ts) / range;
const regionWidth = Math.round(duration * width);
const posLeft = Math.round((ts - timeFrom) / range * width);
const posLeft = Math.round(((ts - timeFrom) / range) * width);
const highlighted = highlightedRegion && highlightedRegion - 1 === index;
const valueClass = `problem-event--${event.value === '1' ? 'problem' : 'ok'}`;
const className = `problem-event-region ${valueClass} ${highlighted ? 'highlighted' : ''}`;
@@ -370,21 +367,16 @@ class TimelineRegions extends PureComponent<TimelineRegionsProps> {
height: height,
};
return (
<rect key={`${event.eventid}-${index}`} className={className} {...attributes} />
);
return <rect key={`${event.eventid}-${index}`} className={className} {...attributes} />;
});
return [
firstItem,
eventsIntervalItems
];
return [firstItem, eventsIntervalItems];
}
}
interface TimelinePointsProps {
events: ZBXEvent[];
timeRange: GFTimeRange;
timeRange: TimeRange;
width: number;
pointSize: number;
okColor?: string;
@@ -415,7 +407,7 @@ class TimelinePoints extends PureComponent<TimelinePointsProps, TimelinePointsSt
order = moveToEnd(order, indexes);
const highlighted = highlight ? indexes : null;
this.setState({ order, highlighted });
}
};
highlightPoint = (index: number) => () => {
let pointsToHighlight = [index];
@@ -429,12 +421,12 @@ class TimelinePoints extends PureComponent<TimelinePointsProps, TimelinePointsSt
}
}
this.bringToFront(pointsToHighlight, true);
}
};
getRegionEvents(index: number) {
const events = this.props.events;
const event = events[index];
if (event.value === '1' && index < events.length ) {
if (event.value === '1' && index < events.length) {
// Problem event
for (let i = index; i < events.length; i++) {
if (events[i].value === '0') {
@@ -459,22 +451,23 @@ class TimelinePoints extends PureComponent<TimelinePointsProps, TimelinePointsSt
return [index];
}
unHighlightPoint = index => () => {
unHighlightPoint = (index) => () => {
if (this.props.onPointUnHighlight) {
this.props.onPointUnHighlight();
}
const order = this.props.events.map((v, i) => i);
this.setState({ order, highlighted: [] });
}
};
render() {
const { events, timeRange, width, pointSize } = this.props;
const { timeFrom, timeTo } = timeRange;
const timeFrom = timeRange.from.unix();
const timeTo = timeRange.to.unix();
const range = timeTo - timeFrom;
const pointR = pointSize / 2;
const eventsItems = events.map((event, i) => {
const ts = Number(event.clock);
const posLeft = Math.round((ts - timeFrom) / range * width - pointR);
const posLeft = Math.round(((ts - timeFrom) / range) * width - pointR);
const className = `problem-event-item problem-event--${event.value === '1' ? 'problem' : 'ok'}`;
const highlighted = this.state.highlighted.indexOf(i) !== -1;
@@ -491,7 +484,7 @@ class TimelinePoints extends PureComponent<TimelinePointsProps, TimelinePointsSt
);
});
if (this.state.order.length) {
return this.state.order.map(i => eventsItems[i]);
return this.state.order.map((i) => eventsItems[i]);
}
return eventsItems;
}
@@ -528,13 +521,13 @@ class TimelinePoint extends PureComponent<TimelinePointProps, TimelinePointState
if (this.props.onPointHighlight) {
this.props.onPointHighlight();
}
}
};
handleMouseLeave = () => {
if (this.props.onPointUnHighlight) {
this.props.onPointUnHighlight();
}
}
};
render() {
const { x } = this.props;
@@ -543,11 +536,13 @@ class TimelinePoint extends PureComponent<TimelinePointProps, TimelinePointState
const rInner = Math.round(r * INNER_POINT_SIZE);
const className = `${this.props.className || ''} ${this.state.highlighted ? 'highlighted' : ''}`;
return (
<g className={className}
<g
className={className}
transform={`translate(${cx}, 0)`}
filter="url(#dropShadow)"
onMouseOver={this.handleMouseOver}
onMouseLeave={this.handleMouseLeave}>
onMouseLeave={this.handleMouseLeave}
>
<circle cx={0} cy={0} r={r} className="point-border" />
<circle cx={0} cy={0} r={rInner} className="point-core" />
</g>
@@ -557,7 +552,7 @@ class TimelinePoint extends PureComponent<TimelinePointProps, TimelinePointState
interface TimelineAcksProps {
acknowledges: ZBXAcknowledge[];
timeRange: GFTimeRange;
timeRange: TimeRange;
width: number;
size: number;
onHighlight?: (ack: ZBXAcknowledge, index: number) => void;
@@ -581,7 +576,7 @@ class TimelineAcks extends PureComponent<TimelineAcksProps, TimelineAcksState> {
this.props.onHighlight(ack, index);
}
this.bringToFront(index, true);
}
};
handleUnHighlight = () => {
if (this.props.onUnHighlight) {
@@ -589,7 +584,7 @@ class TimelineAcks extends PureComponent<TimelineAcksProps, TimelineAcksState> {
}
const order = this.props.acknowledges.map((v, i) => i);
this.setState({ order, highlighted: null });
}
};
bringToFront = (index: number, highlight = false) => {
const { acknowledges } = this.props;
@@ -597,16 +592,17 @@ class TimelineAcks extends PureComponent<TimelineAcksProps, TimelineAcksState> {
order = moveToEnd(order, [index]);
const highlighted = highlight ? index : null;
this.setState({ order, highlighted });
}
};
render() {
const { acknowledges, timeRange, width, size } = this.props;
const { timeFrom, timeTo } = timeRange;
const timeFrom = timeRange.from.unix();
const timeTo = timeRange.to.unix();
const range = timeTo - timeFrom;
const pointR = size / 2;
const eventsItems = acknowledges.map((ack, i) => {
const ts = Number(ack.clock);
const posLeft = Math.round((ts - timeFrom) / range * width - pointR);
const posLeft = Math.round(((ts - timeFrom) / range) * width - pointR);
const highlighted = this.state.highlighted === i;
return (
@@ -621,7 +617,7 @@ class TimelineAcks extends PureComponent<TimelineAcksProps, TimelineAcksState> {
);
});
if (this.state.order.length) {
return this.state.order.map(i => eventsItems[i]);
return this.state.order.map((i) => eventsItems[i]);
}
return eventsItems;
}
@@ -656,13 +652,13 @@ class TimelineAck extends PureComponent<TimelineAckProps, TimelineAckState> {
if (this.props.onHighlight) {
this.props.onHighlight();
}
}
};
handleUnHighlight = () => {
if (this.props.onUnHighlight) {
this.props.onUnHighlight();
}
}
};
render() {
const { x } = this.props;
@@ -671,11 +667,13 @@ class TimelineAck extends PureComponent<TimelineAckProps, TimelineAckState> {
const rInner = Math.round(r * INNER_POINT_SIZE);
const className = `problem-event-ack ${this.state.highlighted ? 'highlighted' : ''}`;
return (
<g className={className}
<g
className={className}
transform={`translate(${cx}, 0)`}
filter="url(#dropShadow)"
onMouseOver={this.handleHighlight}
onMouseLeave={this.handleUnHighlight}>
onMouseLeave={this.handleUnHighlight}
>
<circle cx={0} cy={0} r={r} className="point-border" />
<circle cx={0} cy={0} r={rInner} className="point-core" />
</g>

View File

@@ -8,17 +8,17 @@ import EventTag from '../EventTag';
import { ProblemDetails } from './ProblemDetails';
import { AckProblemData } from '../AckModal';
import { FAIcon, GFHeartIcon } from '../../../components';
import { GFTimeRange, ProblemsPanelOptions, RTCell, RTResized, TriggerSeverity } from '../../types';
import { ProblemsPanelOptions, RTCell, RTResized, TriggerSeverity } from '../../types';
import { ProblemDTO, ZBXAlert, ZBXEvent, ZBXTag } from '../../../datasource-zabbix/types';
import { APIExecuteScriptResponse, ZBXScript } from '../../../datasource-zabbix/zabbix/connectors/zabbix_api/types';
import { AckCell } from './AckCell';
import { DataSourceRef, TimeRange } from "@grafana/data";
import { DataSourceRef, TimeRange } from '@grafana/data';
export interface ProblemListProps {
problems: ProblemDTO[];
panelOptions: ProblemsPanelOptions;
loading?: boolean;
timeRange?: GFTimeRange;
timeRange?: TimeRange;
range?: TimeRange;
pageSize?: number;
fontSize?: number;
@@ -52,7 +52,7 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
};
}
setRootRef = ref => {
setRootRef = (ref) => {
this.rootRef = ref;
};
@@ -60,8 +60,7 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
return this.props.onProblemAck(problem, data);
};
onExecuteScript = (problem: ProblemDTO, data: AckProblemData) => {
};
onExecuteScript = (problem: ProblemDTO, data: AckProblemData) => {};
handlePageSizeChange = (pageSize, pageIndex) => {
if (this.props.onPageSizeChange) {
@@ -132,10 +131,12 @@ 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, 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 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', id: 'host', show: options.hostField, Cell: hostNameCell },
@@ -143,35 +144,62 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
{ 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,
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, options.okEventColor),
Cell: (props) =>
SeverityCell(
props,
options.triggerSeverity,
options.markAckEvents,
options.ackEventColor,
options.okEventColor
),
},
{
Header: '', id: 'statusIcon', show: options.statusIcon, className: 'problem-status-icon', width: 50,
Header: '',
id: 'statusIcon',
show: options.statusIcon,
className: 'problem-status-icon',
width: 50,
accessor: 'value',
Cell: statusIconCell,
},
{ 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: '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: '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: 'timestamp',
Header: 'Age',
className: 'problem-age',
width: 100,
show: options.ageField,
accessor: 'timestamp',
id: 'age',
Cell: AgeCell,
},
{
Header: 'Time', className: 'last-change', width: 150, accessor: 'timestamp',
Header: 'Time',
className: 'last-change',
width: 150,
accessor: 'timestamp',
id: 'lastchange',
Cell: props => LastChangeCell(props, options.customLastChangeFormat && options.lastChangeFormat),
Cell: (props) => LastChangeCell(props, options.customLastChangeFormat && options.lastChangeFormat),
},
{ Header: '', className: 'custom-expander', width: 60, expander: true, Expander: CustomExpander },
];
@@ -207,25 +235,25 @@ export default class ProblemList extends PureComponent<ProblemListProps, Problem
minRows={0}
loading={this.props.loading}
noDataText="No problems found"
SubComponent={props =>
<ProblemDetails {...props}
rootWidth={this.rootWidth}
timeRange={this.props.timeRange}
range={this.props.range}
showTimeline={panelOptions.problemTimeline}
panelId={this.props.panelId}
getProblemEvents={this.props.getProblemEvents}
getProblemAlerts={this.props.getProblemAlerts}
getScripts={this.props.getScripts}
onProblemAck={this.handleProblemAck}
onExecuteScript={this.props.onExecuteScript}
onTagClick={this.handleTagClick}
subRows={false}
SubComponent={(props) => (
<ProblemDetails
{...props}
rootWidth={this.rootWidth}
timeRange={this.props.timeRange}
showTimeline={panelOptions.problemTimeline}
panelId={this.props.panelId}
getProblemEvents={this.props.getProblemEvents}
getProblemAlerts={this.props.getProblemAlerts}
getScripts={this.props.getScripts}
onProblemAck={this.handleProblemAck}
onExecuteScript={this.props.onExecuteScript}
onTagClick={this.handleTagClick}
subRows={false}
/>
}
)}
expanded={this.getExpandedPage(this.state.page)}
onExpandedChange={this.handleExpandedChange}
onPageChange={page => this.setState({ page })}
onPageChange={(page) => this.setState({ page })}
onPageSizeChange={this.handlePageSizeChange}
onResizedChange={this.handleResizedChange}
/>
@@ -243,7 +271,7 @@ const HostCell: React.FC<HostCellProps> = ({ name, maintenance }) => {
return (
<div>
<span style={{ paddingRight: '0.4rem' }}>{name}</span>
{maintenance && <FAIcon customClass="fired" icon="wrench"/>}
{maintenance && <FAIcon customClass="fired" icon="wrench" />}
</div>
);
};
@@ -260,15 +288,15 @@ function SeverityCell(
let severityDesc: TriggerSeverity;
const severity = Number(problem.severity);
severityDesc = _.find(problemSeverityDesc, s => s.priority === severity);
severityDesc = _.find(problemSeverityDesc, (s) => s.priority === severity);
if (problem.severity && problem.value === '1') {
severityDesc = _.find(problemSeverityDesc, s => s.priority === severity);
severityDesc = _.find(problemSeverityDesc, (s) => s.priority === severity);
}
color = problem.value === '0' ? okColor : severityDesc.color;
// Mark acknowledged triggers with different color
if (markAckEvents && problem.acknowledged === "1") {
if (markAckEvents && problem.acknowledged === '1') {
color = ackEventColor;
}
@@ -290,7 +318,9 @@ function StatusCell(props: RTCell<ProblemDTO>, highlightNewerThan?: string) {
newProblem = isNewProblem(props.original, highlightNewerThan);
}
return (
<span className={newProblem ? 'problem-status--new' : ''} style={{ color }}>{status}</span>
<span className={newProblem ? 'problem-status--new' : ''} style={{ color }}>
{status}
</span>
);
}
@@ -300,22 +330,21 @@ function StatusIconCell(props: RTCell<ProblemDTO>, highlightNewerThan?: string)
if (highlightNewerThan) {
newProblem = isNewProblem(props.original, highlightNewerThan);
}
const className = classNames('zbx-problem-status-icon',
const className = classNames(
'zbx-problem-status-icon',
{ 'problem-status--new': newProblem },
{ 'zbx-problem': props.value === '1' },
{ 'zbx-ok': props.value === '0' },
{ 'zbx-ok': props.value === '0' }
);
return <GFHeartIcon status={status} className={className}/>;
return <GFHeartIcon status={status} className={className} />;
}
function GroupCell(props: RTCell<ProblemDTO>) {
let groups = "";
let groups = '';
if (props.value && props.value.length) {
groups = props.value.map(g => g.name).join(', ');
groups = props.value.map((g) => g.name).join(', ');
}
return (
<span>{groups}</span>
);
return <span>{groups}</span>;
}
function ProblemCell(props: RTCell<ProblemDTO>) {
@@ -336,7 +365,7 @@ function AgeCell(props: RTCell<ProblemDTO>) {
}
function LastChangeCell(props: RTCell<ProblemDTO>, customFormat?: string) {
const DEFAULT_TIME_FORMAT = "DD MMM YYYY HH:mm:ss";
const DEFAULT_TIME_FORMAT = 'DD MMM YYYY HH:mm:ss';
const problem = props.original;
const timestamp = moment.unix(problem.timestamp);
const format = customFormat || DEFAULT_TIME_FORMAT;
@@ -358,14 +387,21 @@ class TagCell extends PureComponent<TagCellProps> {
render() {
const tags = this.props.value || [];
return [
tags.map(tag => <EventTag key={tag.tag + tag.value} tag={tag} datasource={this.props.original.datasource} onClick={this.handleTagClick}/>)
tags.map((tag) => (
<EventTag
key={tag.tag + tag.value}
tag={tag}
datasource={this.props.original.datasource}
onClick={this.handleTagClick}
/>
)),
];
}
}
function CustomExpander(props: RTCell<any>) {
return (
<span className={props.isExpanded ? "expanded" : ""}>
<span className={props.isExpanded ? 'expanded' : ''}>
<i className="fa fa-info-circle"></i>
</span>
);

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Button } from '@grafana/ui';
import { StandardEditorProps } from '@grafana/data';
type Props = StandardEditorProps<any>;
export const ResetColumnsEditor = ({ onChange }: Props): JSX.Element => {
return (
<Button variant="secondary" onClick={() => onChange([])}>
Reset columns
</Button>
);
};

View File

@@ -1,18 +1,20 @@
import _ from 'lodash';
import { getNextRefIdChar } from './utils';
import { ShowProblemTypes } from '../datasource-zabbix/types';
import { ProblemsPanelOptions } from './types';
import { PanelModel } from '@grafana/data';
// Actual schema version
export const CURRENT_SCHEMA_VERSION = 8;
export const getDefaultTarget = (targets?) => {
return {
group: {filter: ""},
host: {filter: ""},
application: {filter: ""},
trigger: {filter: ""},
tags: {filter: ""},
proxy: {filter: ""},
group: { filter: '' },
host: { filter: '' },
application: { filter: '' },
trigger: { filter: '' },
tags: { filter: '' },
proxy: { filter: '' },
refId: getNextRefIdChar(targets),
};
};
@@ -105,7 +107,7 @@ export function migratePanelSchema(panel) {
target.options = migrateOptions(panel);
_.defaults(target.options, getDefaultTargetOptions());
_.defaults(target, { tags: { filter: "" } });
_.defaults(target, { tags: { filter: '' } });
}
panel.sortProblems = panel.sortTriggersBy?.value === 'priority' ? 'priority' : 'lastchange';
@@ -161,7 +163,7 @@ function isEmptyPanel(panel) {
}
function isEmptyTargets(targets) {
return !targets || (_.isArray(targets) && (targets.length === 0 || targets.length === 1 && _.isEmpty(targets[0])));
return !targets || (_.isArray(targets) && (targets.length === 0 || (targets.length === 1 && _.isEmpty(targets[0]))));
}
function isDefaultPanel(panel) {
@@ -169,7 +171,13 @@ function isDefaultPanel(panel) {
}
function isDefaultTarget(target) {
return !target.group?.filter && !target.host?.filter && !target.application?.filter && !target.trigger?.filter && !target.queryType;
return (
!target.group?.filter &&
!target.host?.filter &&
!target.application?.filter &&
!target.trigger?.filter &&
!target.queryType
);
}
function isEmptyTarget(target) {
@@ -179,3 +187,53 @@ function isEmptyTarget(target) {
function isInvalidTarget(target, targetKey) {
return target && target.refId === 'A' && targetKey === '0';
}
// This is called when the panel changes from another panel
export const problemsPanelMigrationHandler = (panel: PanelModel<Partial<ProblemsPanelOptions>> | any) => {
let options = (panel.options ?? {}) as ProblemsPanelOptions;
const legacyOptions: Partial<ProblemsPanelOptions> = {
layout: panel.layout,
hostField: panel.hostField,
hostTechNameField: panel.hostTechNameField,
hostGroups: panel.hostGroups,
hostProxy: panel.hostProxy,
showTags: panel.showTags,
statusField: panel.statusField,
statusIcon: panel.statusIcon,
severityField: panel.severityField,
ackField: panel.ackField,
ageField: panel.ageField,
descriptionField: panel.descriptionField,
descriptionAtNewLine: panel.descriptionAtNewLine,
hostsInMaintenance: panel.hostsInMaintenance,
showTriggers: panel.showTriggers,
sortProblems: panel.sortProblems,
limit: panel.limit,
fontSize: panel.fontSize,
pageSize: panel.pageSize,
problemTimeline: panel.problemTimeline,
highlightBackground: panel.highlightBackground,
highlightNewEvents: panel.highlightNewEvents,
highlightNewerThan: panel.highlightNewerThan,
customLastChangeFormat: panel.customLastChangeFormat,
lastChangeFormat: panel.lastChangeFormat,
resizedColumns: panel.resizedColumns,
triggerSeverity: panel.triggerSeverity,
okEventColor: panel.okEventColor,
ackEventColor: panel.ackEventColor,
markAckEvents: panel.markAckEvents,
showEvents: panel.showEvents?.value ?? panel.showEvents,
};
return { ...legacyOptions, ...options };
};
// This is called when the panel changes from another panel
export const problemsPanelChangedHandler = (
panel: PanelModel<Partial<ProblemsPanelOptions>> | any,
prevPluginId: string,
prevOptions: any
) => {
let options = (panel.options ?? {}) as ProblemsPanelOptions;
return options;
};

View File

@@ -1,24 +0,0 @@
/**
* Grafana-Zabbix
* Zabbix plugin for Grafana.
* http://github.com/alexanderzobnin/grafana-zabbix
*
* Trigger panel.
* This feature sponsored by CORE IT
* http://www.coreit.fr
*
* Copyright 2015 Alexander Zobnin alexanderzobnin@gmail.com
* Licensed under the Apache License, Version 2.0
*/
import { TriggerPanelCtrl } from './triggers_panel_ctrl';
import { loadPluginCss } from 'grafana/app/plugins/sdk';
loadPluginCss({
dark: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.dark.css',
light: 'plugins/alexanderzobnin-zabbix-app/css/grafana-zabbix.light.css'
});
export {
TriggerPanelCtrl as PanelCtrl
};

View File

@@ -0,0 +1,214 @@
import { PanelPlugin, StandardEditorProps } from '@grafana/data';
import { problemsPanelChangedHandler, problemsPanelMigrationHandler } from './migrations';
import { ProblemsPanel } from './ProblemsPanel';
import { defaultPanelOptions, ProblemsPanelOptions } from './types';
import { ResetColumnsEditor } from './components/ResetColumnsEditor';
import { ProblemColorEditor } from './components/ProblemColorEditor';
export const plugin = new PanelPlugin<ProblemsPanelOptions, {}>(ProblemsPanel)
.setPanelChangeHandler(problemsPanelChangedHandler)
.setMigrationHandler(problemsPanelMigrationHandler)
.setPanelOptions((builder) => {
builder
.addSelect({
path: 'layout',
name: 'Layout',
defaultValue: defaultPanelOptions.layout,
settings: {
options: [
{ label: 'Table', value: 'table' },
{ label: 'List', value: 'list' },
],
},
})
.addSelect({
path: 'sortProblems',
name: 'Sort by',
defaultValue: defaultPanelOptions.sortProblems,
settings: {
options: [
{ label: 'Default', value: 'default' },
{ label: 'Last change', value: 'lastchange' },
{ label: 'Severity', value: 'priority' },
],
},
})
.addSelect({
path: 'fontSize',
name: 'Font size',
defaultValue: defaultPanelOptions.fontSize,
settings: {
options: fontSizeOptions,
},
})
.addNumberInput({
path: 'pageSize',
name: 'Page size',
defaultValue: defaultPanelOptions.pageSize,
})
.addBooleanSwitch({
path: 'problemTimeline',
name: 'Problem timeline',
defaultValue: defaultPanelOptions.problemTimeline,
showIf: (options) => options.layout === 'table',
})
.addBooleanSwitch({
path: 'highlightBackground',
name: 'Highlight background',
defaultValue: defaultPanelOptions.highlightBackground,
showIf: (options) => options.layout === 'list',
})
.addBooleanSwitch({
path: 'highlightNewEvents',
name: 'Highlight new events',
defaultValue: defaultPanelOptions.highlightNewEvents,
})
.addTextInput({
path: 'highlightNewerThan',
name: 'Newer than',
defaultValue: defaultPanelOptions.highlightNewerThan,
showIf: (options) => options.highlightNewEvents,
})
.addBooleanSwitch({
path: 'customLastChangeFormat',
name: 'Custom last change format',
defaultValue: defaultPanelOptions.customLastChangeFormat,
})
.addTextInput({
path: 'lastChangeFormat',
name: 'Last change format',
defaultValue: defaultPanelOptions.lastChangeFormat,
description: 'See moment.js dosc for time format http://momentjs.com/docs/#/displaying/format/',
settings: {
placeholder: 'dddd, MMMM Do YYYY, h:mm:ss a',
},
showIf: (options) => options.customLastChangeFormat,
})
.addCustomEditor({
id: 'resetColumns',
path: 'resizedColumns',
name: 'Reset resized columns',
editor: ResetColumnsEditor,
showIf: (options) => options.layout === 'table',
})
.addCustomEditor({
id: 'triggerColors',
path: 'triggerSeverity',
name: 'Problem colors',
editor: ProblemColorEditor,
category: ['Colors'],
})
.addBooleanSwitch({
path: 'markAckEvents',
name: 'Mark acknowledged events',
defaultValue: defaultPanelOptions.markAckEvents,
category: ['Colors'],
})
.addColorPicker({
path: 'ackEventColor',
name: 'Acknowledged color',
defaultValue: defaultPanelOptions.ackEventColor,
showIf: (options) => options.markAckEvents,
// enableNamedColors does not work now
settings: [{ enableNamedColors: false }],
category: ['Colors'],
})
.addColorPicker({
path: 'okEventColor',
name: 'OK event color',
defaultValue: defaultPanelOptions.okEventColor,
settings: [{ enableNamedColors: false }],
category: ['Colors'],
})
// Show/hide fields
.addBooleanSwitch({
path: 'hostField',
name: 'Host name',
defaultValue: defaultPanelOptions.hostField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'hostTechNameField',
name: 'Technical name',
defaultValue: defaultPanelOptions.hostTechNameField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'hostGroups',
name: 'Host groups',
defaultValue: defaultPanelOptions.hostGroups,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'hostProxy',
name: 'Host proxy',
defaultValue: defaultPanelOptions.hostProxy,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'showTags',
name: 'Tags',
defaultValue: defaultPanelOptions.showTags,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'statusField',
name: 'Status',
defaultValue: defaultPanelOptions.statusField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'statusIcon',
name: 'Status icon',
defaultValue: defaultPanelOptions.statusIcon,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'severityField',
name: 'Severity',
defaultValue: defaultPanelOptions.severityField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'ackField',
name: 'Ack',
defaultValue: defaultPanelOptions.ackField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'ageField',
name: 'Age',
defaultValue: defaultPanelOptions.ageField,
category: ['Fields'],
})
.addBooleanSwitch({
path: 'descriptionField',
name: 'Description',
defaultValue: defaultPanelOptions.descriptionField,
showIf: (options) => options.layout === 'list',
category: ['Fields'],
})
.addBooleanSwitch({
path: 'descriptionAtNewLine',
name: 'At the new line',
defaultValue: defaultPanelOptions.descriptionAtNewLine,
showIf: (options) => options.layout === 'list',
category: ['Fields'],
});
});
const fontSizeOptions = [
{ label: '80%', value: '80%' },
{ label: '90%', value: '90%' },
{ label: '100%', value: '100%' },
{ label: '110%', value: '110%' },
{ label: '120%', value: '120%' },
{ label: '130%', value: '130%' },
{ label: '150%', value: '150%' },
{ label: '160%', value: '160%' },
{ label: '180%', value: '180%' },
{ label: '200%', value: '200%' },
{ label: '220%', value: '220%' },
{ label: '250%', value: '250%' },
];

View File

@@ -1,54 +0,0 @@
/**
* Grafana-Zabbix
* Zabbix plugin for Grafana.
* http://github.com/alexanderzobnin/grafana-zabbix
*
* Trigger panel.
* This feature sponsored by CORE IT
* http://www.coreit.fr
*
* Copyright 2015 Alexander Zobnin alexanderzobnin@gmail.com
* Licensed under the Apache License, Version 2.0
*/
class TriggerPanelOptionsCtrl {
/** @ngInject */
constructor($scope) {
$scope.editor = this;
this.panelCtrl = $scope.ctrl;
this.panel = this.panelCtrl.panel;
this.layouts = [
{ text: 'Table', value: 'table' },
{ text: 'List', value: 'list' }
];
this.fontSizes = ['80%', '90%', '100%', '110%', '120%', '130%', '150%', '160%', '180%', '200%', '220%', '250%'];
this.ackFilters = [
'all triggers',
'unacknowledged',
'acknowledged'
];
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] },
{ text: 'Problems', value: 1 }
];
}
}
export function triggerPanelOptionsTab() {
return {
restrict: 'E',
scope: true,
templateUrl: 'public/plugins/alexanderzobnin-zabbix-app/panel-triggers/partials/options_tab.html',
controller: TriggerPanelOptionsCtrl,
};
}

View File

@@ -1 +0,0 @@
<div class="triggers-panel-container"></div>

View File

@@ -1,237 +0,0 @@
<div class="editor-row">
<div class="section gf-form-group">
<h5 class="section-heading">Fields</h5>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Host name"
checked="ctrl.panel.hostField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Technical name"
checked="ctrl.panel.hostTechNameField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Host groups"
checked="ctrl.panel.hostGroups"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Host proxy"
checked="ctrl.panel.hostProxy"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Tags"
checked="ctrl.panel.showTags"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Status"
checked="ctrl.panel.statusField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch ng-if="ctrl.panel.layout === 'table'"
class="gf-form"
label-class="width-9"
label="Status Icon"
checked="ctrl.panel.statusIcon"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-9"
label="Severity"
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"
checked="ctrl.panel.ageField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch ng-if="ctrl.panel.layout === 'list'"
class="gf-form"
label-class="width-9"
label="Description"
checked="ctrl.panel.descriptionField"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch ng-if="ctrl.panel.descriptionField && ctrl.panel.layout === 'list'"
class="gf-form"
label-class="width-9"
label="At the new line"
checked="ctrl.panel.descriptionAtNewLine"
on-change="ctrl.render()">
</gf-form-switch>
</div>
<div class="section gf-form-group">
<h5 class="section-heading">View options</h5>
<div class="gf-form">
<label class="gf-form-label width-10">Layout</label>
<div class="gf-form-select-wrapper max-width-8">
<select class="gf-form-input"
ng-model="ctrl.panel.layout"
ng-options="opt.value as opt.text for opt in editor.layouts"
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">
<select class="gf-form-input"
ng-model="ctrl.panel.fontSize"
ng-options="f for f in editor.fontSizes"
ng-change="ctrl.render()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-10">Page size</label>
<input class="gf-form-input width-8"
type="number" placeholder="10"
ng-model="ctrl.panel.pageSize"
ng-model-onblur ng-change="ctrl.render()">
</div>
<gf-form-switch ng-if="ctrl.panel.layout === 'table'"
class="gf-form"
label-class="width-10"
label="Problem timeline"
checked="ctrl.panel.problemTimeline"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch ng-if="ctrl.panel.layout !== 'table'"
class="gf-form"
label-class="width-10"
label="Highlight background"
checked="ctrl.panel.highlightBackground"
on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label-class="width-10"
label="Highlight new events"
checked="ctrl.panel.highlightNewEvents"
on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-10">Newer than</label>
<input class="gf-form-input width-8" placeholder="1h"
ng-model="ctrl.panel.highlightNewerThan"
ng-model-onblur ng-change="ctrl.render()">
</div>
<gf-form-switch class="gf-form"
label-class="width-16"
label="Custom Last change format"
checked="ctrl.panel.customLastChangeFormat"
on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form" ng-if="ctrl.panel.customLastChangeFormat">
<label class="gf-form-label width-3">
<a href="http://momentjs.com/docs/#/displaying/format/" target="_blank">
<tip>See moment.js dosc for time format.</tip>
</a>
</label>
<input class="gf-form-input width-18"
type="text"
placeholder="dddd, MMMM Do YYYY, h:mm:ss a"
empty-to-null
ng-model-onblur
ng-model="ctrl.panel.lastChangeFormat"
ng-change="ctrl.render()">
</div>
<div class="gf-form" ng-if="ctrl.panel.layout === 'table'">
<div class="gf-form-button">
<button class="btn btn-inverse width-16 panel-options-button" ng-click="ctrl.resetResizedColumns()">
Reset resized columns
</button>
</div>
</div>
</div>
<div class="section gf-form-group">
<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>
<label class="gf-form-label triggers-severity-config"
ng-style="{color: trigger.color}">
<i class="icon-gf" ng-class="ctrl.getAlertIconClassBySeverity(trigger)"></i>
</label>
<input type="text"
class="gf-form-input width-12"
empty-to-null
ng-model="trigger.severity"
ng-model-onblur
ng-change="ctrl.render()">
<span class="gf-form-label">
<spectrum-picker ng-model="trigger.color" ng-change="ctrl.render()"></spectrum-picker>
</span>
</div>
<gf-form-switch class="gf-form"
label-class="width-0"
label="Show"
checked="trigger.show"
on-change="ctrl.reRenderProblems()">
</gf-form-switch>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-3">&nbsp;</label>
<label class="gf-form-label triggers-severity-config"
ng-style="{color: ctrl.panel.ackEventColor}">
<i class="icon-gf icon-gf-online"></i>
</label>
<label class="gf-form-label width-12">
Acknowledged color
</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.ackEventColor" ng-change="ctrl.render()"></spectrum-picker>
</span>
</div>
<gf-form-switch class="gf-form"
label-class="width-0"
label="Show"
checked="ctrl.panel.markAckEvents"
on-change="ctrl.reRenderProblems()">
</gf-form-switch>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-3">&nbsp;</label>
<label class="gf-form-label triggers-severity-config"
ng-style="{color: ctrl.panel.okEventColor}">
<i class="icon-gf icon-gf-online"></i>
</label>
<label class="gf-form-label width-12">
OK event color
</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.okEventColor" ng-change="ctrl.render()"></spectrum-picker>
</span>
</div>
</div>
</div>
</div>

View File

@@ -1,13 +0,0 @@
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

View File

@@ -1,96 +0,0 @@
import _ from 'lodash';
import './matchMedia.mock';
import { DEFAULT_SEVERITY, DEFAULT_TARGET, PANEL_DEFAULTS, TriggerPanelCtrl } 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 timeoutMock = () => {
};
beforeEach(() => {
ctx = {
scope: {
panel: {
datasource: 'zabbix',
triggers: DEFAULT_TARGET,
hostField: true,
statusField: false,
severityField: false,
lastChangeField: true,
ageField: true,
infoField: true,
limit: 10,
showTriggers: 'unacknowledged',
hideHostsInMaintenance: false,
hostsInMaintenance: false,
sortTriggersBy: { text: 'last change', value: 'lastchange' },
showEvents: { text: 'Problems', value: '1' },
triggerSeverity: DEFAULT_SEVERITY,
okEventColor: 'rgba(0, 245, 153, 0.45)',
ackEventColor: 'rgba(0, 0, 0, 0)',
scroll: true,
pageSize: 10,
fontSize: '100%',
}
}
};
updatePanelCtrl = (scope) => new TriggerPanelCtrl(scope, {}, timeoutMock);
});
it('should update old panel schema', () => {
const updatedPanelCtrl = updatePanelCtrl(ctx.scope);
const expected = _.defaultsDeep({
schemaVersion: CURRENT_SCHEMA_VERSION,
datasource: 'zabbix',
targets: [
{
...DEFAULT_TARGET,
queryType: 5,
showProblems: 'problems',
options: {
hostsInMaintenance: false,
acknowledged: 0,
sortProblems: 'default',
minSeverity: 0,
limit: 10,
},
}
],
sortProblems: 'lastchange',
ageField: true,
statusField: false,
severityField: false,
limit: 10,
okEventColor: 'rgba(0, 245, 153, 0.45)',
ackEventColor: 'rgba(0, 0, 0, 0)'
}, PANEL_DEFAULTS);
expect(updatedPanelCtrl.panel).toEqual(expected);
});
it('should create new panel with default schema', () => {
ctx.scope.panel = {};
const updatedPanelCtrl = updatePanelCtrl(ctx.scope);
const expected = _.defaultsDeep({
schemaVersion: CURRENT_SCHEMA_VERSION,
}, PANEL_DEFAULTS);
expect(updatedPanelCtrl.panel).toEqual(expected);
});
});

View File

@@ -1,211 +0,0 @@
import _ from 'lodash';
import './matchMedia.mock';
import { DEFAULT_TARGET, PANEL_DEFAULTS, TriggerPanelCtrl } from '../triggers_panel_ctrl';
let datasourceSrvMock, zabbixDSMock;
jest.mock('@grafana/runtime', () => {
return {
getDataSourceSrv: () => datasourceSrvMock,
};
}, { virtual: true });
describe('TriggerPanelCtrl', () => {
let ctx: any = {};
let createPanelCtrl: () => any;
beforeEach(() => {
ctx = {
scope: {
panel: {
...PANEL_DEFAULTS,
sortProblems: 'lastchange',
}
}
};
ctx.scope.panel.targets = [{
...DEFAULT_TARGET,
datasource: 'zabbix_default',
}];
zabbixDSMock = {
zabbix: {
getExtendedEventData: jest.fn().mockResolvedValue([]),
getEventAlerts: jest.fn().mockResolvedValue([]),
}
};
datasourceSrvMock = {
get: () => Promise.resolve(zabbixDSMock)
};
const timeoutMock = (fn: () => any) => Promise.resolve(fn());
createPanelCtrl = () => new TriggerPanelCtrl(ctx.scope, {}, timeoutMock);
ctx.panelCtrl = createPanelCtrl();
ctx.dataFramesReceived = generateDataFramesResponse([
{ id: "1", timestamp: "1510000010", severity: 5 },
{ id: "2", timestamp: "1510000040", severity: 3 },
{ id: "3", timestamp: "1510000020", severity: 4 },
{ id: "4", timestamp: "1510000030", severity: 2 },
]);
});
describe('When refreshing panel', () => {
beforeEach(() => {
ctx.scope.panel.datasources = ['zabbix_default', 'zabbix'];
ctx.scope.panel.targets = [
{
...DEFAULT_TARGET,
datasource: 'zabbix_default'
},
{
...DEFAULT_TARGET,
datasource: 'zabbix'
},
];
ctx.panelCtrl = createPanelCtrl();
});
it('should format triggers', (done) => {
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');
expect(formattedTrigger.maintenance).toBe(true);
expect(formattedTrigger.lastchange).toBeTruthy();
done();
});
});
it('should sort triggers by time by default', (done) => {
ctx.panelCtrl.onDataFramesReceived(ctx.dataFramesReceived).then(() => {
const trigger_ids = _.map(ctx.panelCtrl.renderData, 'triggerid');
expect(trigger_ids).toEqual([
'2', '4', '3', '1'
]);
done();
});
});
it('should sort triggers by severity', (done) => {
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();
});
});
});
});
const defaultProblem: any = {
"acknowledges": [],
"comments": "It probably means that the systems requires\nmore physical memory.",
"correlation_mode": "0",
"correlation_tag": "",
"datasource": "zabbix_default",
"description": "Lack of free swap space on server",
"error": "",
"expression": "{13297}>20",
"flags": "0",
"groups": [
{
"groupid": "2",
"name": "Linux servers"
},
{
"groupid": "9",
"name": "Backend"
}
],
"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",
"severity": "2",
"recovery_expression": "",
"recovery_mode": "0",
"showAckButton": true,
"state": "0",
"status": "0",
"tags": [],
"templateid": "13671",
"triggerid": "13682",
"type": "0",
"url": "",
"value": "1"
};
function generateDataFramesResponse(problemDescs: any[] = [{ id: 1 }]): any {
const problems = problemDescs.map(problem => generateProblem(problem.id, problem.timestamp, problem.severity));
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();
problem.eventid = id.toString();
if (severity) {
problem.severity = severity.toString();
}
if (timestamp) {
problem.lastchange = timestamp;
problem.timestamp = timestamp;
}
return problem;
}
function getProblemById(id, ctx): any {
return _.find(ctx.panelCtrl.renderData, { triggerid: id.toString() });
}

View File

@@ -1,456 +0,0 @@
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 { MetricsPanelCtrl } from 'grafana/app/plugins/sdk';
import { triggerPanelOptionsTab } from './options_tab';
import { CURRENT_SCHEMA_VERSION, migratePanelSchema } 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,
targets: [{}],
// 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(problems: ProblemDTO[]) {
if (this.panel.sortProblems === 'priority') {
problems = _.orderBy(problems, ['severity', 'timestamp', 'eventid'], ['desc', 'desc', 'desc']);
} else if (this.panel.sortProblems === 'lastchange') {
problems = _.orderBy(problems, ['timestamp', 'severity', 'eventid'], ['desc', 'desc', 'desc']);
}
return problems;
}
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?.uid === datasource?.uid || 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?.uid === datasource?.uid || 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);
});
}
getProblemScripts(problem: ProblemDTO) {
const hostid = problem.hosts?.length ? problem.hosts[0].hostid : null;
return getDataSourceSrv().get(problem.datasource)
.then((datasource: any) => {
return datasource.zabbix.getScripts([hostid]);
});
}
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);
});
}
executeScript(problem: ProblemDTO, scriptid: string) {
const hostid = problem.hosts?.length ? problem.hosts[0].hostid : null;
return getDataSourceSrv().get(problem.datasource)
.then((datasource: any) => {
return datasource.zabbix.executeScript(hostid, scriptid);
});
}
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);
ctrl.renderingCompleted();
});
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 },
range: ctrl.range,
loading,
pageSize,
fontSize: fontSizeProp,
panelId: ctrl.panel.id,
getProblemEvents: ctrl.getProblemEvents.bind(ctrl),
getProblemAlerts: ctrl.getProblemAlerts.bind(ctrl),
getScripts: ctrl.getProblemScripts.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);
},
onExecuteScript: ctrl.executeScript.bind(ctrl),
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,9 +1,11 @@
import { DataSourceRef } from "@grafana/data";
import { DataSourceRef } from '@grafana/data';
import { CURRENT_SCHEMA_VERSION } from './migrations';
export interface ProblemsPanelOptions {
schemaVersion: number;
datasources: any[];
targets: ProblemsPanelTarget[];
layout: 'table' | 'list';
// Fields
hostField?: boolean;
hostTechNameField?: boolean;
@@ -19,15 +21,9 @@ export interface ProblemsPanelOptions {
descriptionAtNewLine?: boolean;
// Options
hostsInMaintenance?: boolean;
showTriggers?: 'all triggers' | 'unacknowledged' | 'acknowledges';
sortTriggersBy?: {
text: string;
value: 'lastchange' | 'priority';
};
showEvents?: {
text: 'All' | 'OK' | 'Problems';
value: 1 | Array<0 | 1>;
};
showTriggers?: 'all triggers' | 'unacknowledged' | 'acknowledged';
sortProblems?: 'default' | 'lastchange' | 'priority';
showEvents?: Number[];
limit?: number;
// View options
fontSize?: string;
@@ -46,24 +42,71 @@ export interface ProblemsPanelOptions {
markAckEvents?: boolean;
}
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;
export const defaultPanelOptions: Partial<ProblemsPanelOptions> = {
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,
};
export interface ProblemsPanelTarget {
group: {
filter: string
filter: string;
};
host: {
filter: string
filter: string;
};
application: {
filter: string
filter: string;
};
trigger: {
filter: string
filter: string;
};
tags: {
filter: string
filter: string;
};
proxy: {
filter: string
filter: string;
};
datasource: string;
}
@@ -77,108 +120,6 @@ export interface TriggerSeverity {
export type TriggerColor = string;
export interface ZBXTrigger {
acknowledges?: ZBXAcknowledge[];
showAckButton?: boolean;
alerts?: ZBXAlert[];
age?: string;
color?: TriggerColor;
comments?: string;
correlation_mode?: string;
correlation_tag?: string;
datasource?: DataSourceRef | string;
description?: string;
error?: string;
expression?: string;
flags?: string;
groups?: ZBXGroup[];
host?: string;
hostTechName?: string;
hosts?: ZBXHost[];
items?: ZBXItem[];
lastEvent?: ZBXEvent;
lastchange?: string;
lastchangeUnix?: number;
maintenance?: boolean;
manual_close?: string;
priority?: string;
proxy?: string;
recovery_expression?: string;
recovery_mode?: string;
severity?: string;
state?: string;
status?: string;
tags?: ZBXTag[];
templateid?: string;
triggerid?: string;
/** Whether the trigger can generate multiple problem events. */
type?: string;
url?: string;
value?: string;
}
export interface ZBXGroup {
groupid: string;
name: string;
}
export interface ZBXHost {
hostid: string;
name: string;
}
export interface ZBXItem {
itemid: string;
name: string;
key_: string;
lastvalue?: string;
}
export interface ZBXEvent {
eventid: string;
clock: string;
ns?: string;
value?: string;
source?: string;
object?: string;
objectid?: string;
acknowledged?: string;
severity?: string;
hosts?: ZBXHost[];
acknowledges?: ZBXAcknowledge[];
}
export interface ZBXTag {
tag: string;
value?: string;
}
export interface ZBXAcknowledge {
acknowledgeid: string;
eventid: string;
userid: string;
action: string;
clock: string;
time: string;
message?: string;
user: string;
alias: string;
name: string;
surname: string;
}
export interface ZBXAlert {
eventid: string;
clock: string;
message: string;
error: string;
}
export interface GFTimeRange {
timeFrom: number;
timeTo: number;
}
export interface RTRow<T> {
/** the materialized row of data */
row: any;