diff --git a/docs/sources/configuration/index.md b/docs/sources/configuration/index.md index ed2b24962..7bbdc7eb3 100644 --- a/docs/sources/configuration/index.md +++ b/docs/sources/configuration/index.md @@ -68,12 +68,9 @@ Read [how to configure](./direct_db_datasource) SQL data source in Grafana. **MySQL**, **PostgreSQL**, **InfluxDB** are supported as sources of historical data for the plugin. -### Alerting +### Other -- **Enable alerting**: enable limited alerting support. -- **Add thresholds**: get thresholds info from zabbix triggers and add it to graphs. - For example, if you have trigger `{Zabbix server:system.cpu.util[,iowait].avg(5m)}>20`, threshold will be set to 20. -- **Min severity**: minimum trigger severity for showing alert info (OK/Problem). +- **Disable data alignment**: disable time series data alignment. This feature aligns points based on item update interval. For instance, if value collected once per minute, then timestamp of the each point will be set to the start of corresponding minute. This alignment required for proper work of the stacked graphs. If you don't need stacked graphs and want to get exactly the same timestamps as in Zabbix, then you can disable this feature. Also, data alignment can be toggled for each query individually, in the query options. Then click _Add_ - datasource will be added and you can check connection using _Test Connection_ button. This feature can help to find some mistakes like invalid user name diff --git a/docs/sources/configuration/provisioning.md b/docs/sources/configuration/provisioning.md index 582bf356f..b12a59bd7 100644 --- a/docs/sources/configuration/provisioning.md +++ b/docs/sources/configuration/provisioning.md @@ -30,8 +30,6 @@ datasources: alerting: true addThresholds: false alertingMinSeverity: 3 - # Disable acknowledges for read-only users - disableReadOnlyUsersAck: true # Direct DB Connection options dbConnectionEnable: true # Name of existing datasource for Direct DB Connection @@ -39,6 +37,10 @@ datasources: # Retention policy name (InfluxDB only) for fetching long-term stored data. # Leave it blank if only default retention policy used. dbConnectionRetentionPolicy: one_year + # Disable acknowledges for read-only users + disableReadOnlyUsersAck: true + # Disable time series data alignment + disableDataAlignment: false version: 1 editable: false diff --git a/pkg/datasource/response_handler.go b/pkg/datasource/response_handler.go new file mode 100644 index 000000000..8f8220bd6 --- /dev/null +++ b/pkg/datasource/response_handler.go @@ -0,0 +1,49 @@ +package datasource + +import ( + "fmt" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +func convertHistory(history History, items Items) *data.Frame { + timeFileld := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeFileld.Name = "time" + frame := data.NewFrame("History", timeFileld) + + for _, item := range items { + field := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, 0) + if len(item.Hosts) > 0 { + field.Name = fmt.Sprintf("%s: %s", item.Hosts[0].Name, item.ExpandItem()) + } else { + field.Name = item.ExpandItem() + } + frame.Fields = append(frame.Fields, field) + } + + for _, point := range history { + for columnIndex, field := range frame.Fields { + if columnIndex == 0 { + ts := time.Unix(point.Clock, point.NS) + field.Append(ts) + } else { + item := items[columnIndex-1] + if point.ItemID == item.ID { + value := point.Value + field.Append(&value) + } else { + field.Append(nil) + } + } + } + } + + wideFrame, err := data.LongToWide(frame, &data.FillMissing{Mode: data.FillModeNull}) + if err != nil { + backend.Logger.Debug("Error converting data frame to the wide format", "error", err) + return frame + } + return wideFrame +} diff --git a/pkg/datasource/zabbix.go b/pkg/datasource/zabbix.go index 87d67c8fa..7ac18a182 100644 --- a/pkg/datasource/zabbix.go +++ b/pkg/datasource/zabbix.go @@ -374,7 +374,8 @@ func (ds *ZabbixDatasourceInstance) queryNumericDataForItems(ctx context.Context return nil, err } - return convertHistory(history, items), nil + frame := convertHistory(history, items) + return frame, nil } func (ds *ZabbixDatasourceInstance) getTrendValueType(query *QueryModel) string { @@ -472,46 +473,6 @@ func (ds *ZabbixDatasourceInstance) isUseTrend(timeRange backend.TimeRange) bool return false } -func convertHistory(history History, items Items) *data.Frame { - timeFileld := data.NewFieldFromFieldType(data.FieldTypeTime, 0) - timeFileld.Name = "time" - frame := data.NewFrame("History", timeFileld) - - for _, item := range items { - field := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, 0) - if len(item.Hosts) > 0 { - field.Name = fmt.Sprintf("%s: %s", item.Hosts[0].Name, item.ExpandItem()) - } else { - field.Name = item.ExpandItem() - } - frame.Fields = append(frame.Fields, field) - } - - for _, point := range history { - for columnIndex, field := range frame.Fields { - if columnIndex == 0 { - ts := time.Unix(point.Clock, point.NS) - field.Append(ts) - } else { - item := items[columnIndex-1] - if point.ItemID == item.ID { - value := point.Value - field.Append(&value) - } else { - field.Append(nil) - } - } - } - } - - // TODO: convert to wide format - wideFrame, err := data.LongToWide(frame, &data.FillMissing{Mode: data.FillModeNull}) - if err == nil { - return wideFrame - } - return frame -} - func parseFilter(filter string) (*regexp.Regexp, error) { regex := regexp.MustCompile(`^/(.+)/(.*)$`) flagRE := regexp.MustCompile("[imsU]+") diff --git a/pkg/timeseries/models.go b/pkg/timeseries/models.go new file mode 100644 index 000000000..c519ba27b --- /dev/null +++ b/pkg/timeseries/models.go @@ -0,0 +1,18 @@ +package timeseries + +import "time" + +type TimePoint struct { + Time time.Time + Value *float64 +} + +type TimeSeries []TimePoint + +func NewTimeSeries() TimeSeries { + return make(TimeSeries, 0) +} + +func (ts *TimeSeries) Len() int { + return len(*ts) +} diff --git a/pkg/timeseries/timeseries.go b/pkg/timeseries/timeseries.go new file mode 100644 index 000000000..43ba3e7f8 --- /dev/null +++ b/pkg/timeseries/timeseries.go @@ -0,0 +1,153 @@ +package timeseries + +import ( + "errors" + "math" + "sort" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +// Aligns point's time stamps according to provided interval. +func (ts TimeSeries) Align(interval time.Duration) TimeSeries { + if interval <= 0 || ts.Len() < 2 { + return ts + } + + alignedTs := NewTimeSeries() + var frameTs = ts[0].GetTimeFrame(interval) + var pointFrameTs time.Time + var point TimePoint + + for i := 1; i < ts.Len(); i++ { + point = ts[i] + pointFrameTs = point.GetTimeFrame(interval) + + if pointFrameTs.After(frameTs) { + for frameTs.Before(pointFrameTs) { + alignedTs = append(alignedTs, TimePoint{Time: frameTs, Value: nil}) + frameTs = frameTs.Add(interval) + } + } + + alignedTs = append(alignedTs, TimePoint{Time: pointFrameTs, Value: point.Value}) + frameTs = frameTs.Add(interval) + } + + return alignedTs +} + +// Detects interval between data points in milliseconds based on median delta between points. +func (ts TimeSeries) DetectInterval() time.Duration { + if ts.Len() < 2 { + return 0 + } + + deltas := make([]int, 0) + for i := 1; i < ts.Len(); i++ { + delta := ts[i].Time.Sub(ts[i-1].Time) + deltas = append(deltas, int(delta.Milliseconds())) + } + sort.Ints(deltas) + midIndex := int(math.Floor(float64(len(deltas)) * 0.5)) + return time.Duration(deltas[midIndex]) * time.Millisecond +} + +// Gets point timestamp rounded according to provided interval. +func (p *TimePoint) GetTimeFrame(interval time.Duration) time.Time { + return p.Time.Truncate(interval) +} + +func alignDataPoints(frame *data.Frame, interval time.Duration) *data.Frame { + if interval <= 0 || frame.Rows() < 2 { + return frame + } + + timeFieldIdx := getTimeFieldIndex(frame) + if timeFieldIdx < 0 { + return frame + } + var frameTs = getPointTimeFrame(getTimestampAt(frame, 0), interval) + var pointFrameTs *time.Time + var pointsInserted = 0 + + for i := 1; i < frame.Rows(); i++ { + pointFrameTs = getPointTimeFrame(getTimestampAt(frame, i), interval) + if pointFrameTs == nil || frameTs == nil { + continue + } + + if pointFrameTs.After(*frameTs) { + for frameTs.Before(*pointFrameTs) { + insertAt := i + pointsInserted + err := insertNullPointAt(frame, *frameTs, insertAt) + if err != nil { + backend.Logger.Debug("Error inserting null point", "error", err) + } + *frameTs = frameTs.Add(interval) + pointsInserted++ + } + } + + setTimeAt(frame, *pointFrameTs, i+pointsInserted) + *frameTs = frameTs.Add(interval) + } + + return frame +} + +func getPointTimeFrame(ts *time.Time, interval time.Duration) *time.Time { + if ts == nil { + return nil + } + timeFrame := ts.Truncate(interval) + return &timeFrame +} + +func getTimeFieldIndex(frame *data.Frame) int { + for i := 0; i < len(frame.Fields); i++ { + if frame.Fields[i].Type() == data.FieldTypeTime { + return i + } + } + + return -1 +} + +func getTimestampAt(frame *data.Frame, index int) *time.Time { + timeFieldIdx := getTimeFieldIndex(frame) + if timeFieldIdx < 0 { + return nil + } + + tsValue := frame.Fields[timeFieldIdx].At(index) + ts, ok := tsValue.(time.Time) + if !ok { + return nil + } + + return &ts +} + +func insertNullPointAt(frame *data.Frame, frameTs time.Time, index int) error { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeTime { + field.Insert(index, frameTs) + } else if field.Type().Nullable() { + field.Insert(index, nil) + } else { + return errors.New("field is not nullable") + } + } + return nil +} + +func setTimeAt(frame *data.Frame, frameTs time.Time, index int) { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeTime { + field.Insert(index, frameTs) + } + } +} diff --git a/src/datasource-zabbix/components/ConfigEditor.tsx b/src/datasource-zabbix/components/ConfigEditor.tsx index 4fe3d1953..76fa69e85 100644 --- a/src/datasource-zabbix/components/ConfigEditor.tsx +++ b/src/datasource-zabbix/components/ConfigEditor.tsx @@ -35,6 +35,7 @@ export const ConfigEditor = (props: Props) => { trendsRange: '', cacheTTL: '', timeout: '', + disableDataAlignment: false, ...restJsonData, }, }); @@ -209,10 +210,20 @@ export const ConfigEditor = (props: Props) => {

Other

+ ); diff --git a/src/datasource-zabbix/datasource.ts b/src/datasource-zabbix/datasource.ts index d0e487e9d..9e587a0f0 100644 --- a/src/datasource-zabbix/datasource.ts +++ b/src/datasource-zabbix/datasource.ts @@ -6,6 +6,7 @@ import * as utils from './utils'; import * as migrations from './migrations'; import * as metricFunctions from './metricFunctions'; import * as c from './constants'; +import { align } from './timeseries'; import dataProcessor from './dataProcessor'; import responseHandler from './responseHandler'; import problemsHandler from './problemsHandler'; @@ -13,7 +14,7 @@ import { Zabbix } from './zabbix/zabbix'; import { ZabbixAPIError } from './zabbix/connectors/zabbix_api/zabbixAPIConnector'; import { ZabbixMetricsQuery, ZabbixDSOptions, VariableQueryTypes, ShowProblemTypes, ProblemDTO } from './types'; import { getBackendSrv } from '@grafana/runtime'; -import { DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; +import { DataFrame, DataQueryRequest, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, FieldType, isDataFrame, LoadingState } from '@grafana/data'; export class ZabbixDatasource extends DataSourceApi { name: string; @@ -25,6 +26,7 @@ export class ZabbixDatasource extends DataSourceApi): Promise { // Create request for each target const promises = _.map(options.targets, t => { // Don't request for hidden targets @@ -164,7 +167,20 @@ export class ZabbixDatasource extends DataSourceApi { - return { data: data }; + if (data && data.length > 0 && isDataFrame(data[0]) && !utils.isProblemsDataFrame(data[0])) { + data = responseHandler.alignFrames(data); + if (responseHandler.isConvertibleToWide(data)) { + console.log('Converting response to the wide format'); + data = responseHandler.convertToWide(data); + } + } + return data; + }).then(data => { + return { + data, + state: LoadingState.Done, + key: options.requestId, + }; }); } @@ -207,28 +223,31 @@ export class ZabbixDatasource extends DataSourceApi { const getItemOptions = { itemtype: 'num' }; - return this.zabbix.getItemsFromTarget(target, getItemOptions) - .then(items => { - queryStart = new Date().getTime(); - return this.queryNumericDataForItems(items, target, timeRange, useTrends, options); - }).then(result => { - queryEnd = new Date().getTime(); - if (this.enableDebugLog) { - console.log(`Datasource::Performance Query Time (${this.name}): ${queryEnd - queryStart}`); - } - return result; - }); + + const items = await this.zabbix.getItemsFromTarget(target, getItemOptions); + + const queryStart = new Date().getTime(); + const result = await this.queryNumericDataForItems(items, target, timeRange, useTrends, options); + const queryEnd = new Date().getTime(); + + if (this.enableDebugLog) { + console.log(`Datasource::Performance Query Time (${this.name}): ${queryEnd - queryStart}`); + } + + const valueMappings = await this.zabbix.getValueMappings(); + + const dataFrames = result.map(s => responseHandler.seriesToDataFrame(s, target, valueMappings)); + return dataFrames; } /** * Query history for numeric items */ - queryNumericDataForItems(items, target, timeRange, useTrends, options) { + queryNumericDataForItems(items, target: ZabbixMetricsQuery, timeRange, useTrends, options) { let getHistoryPromise; options.valueType = this.getTrendValueType(target); options.consolidateBy = getConsolidateBy(target) || options.valueType; @@ -236,7 +255,11 @@ export class ZabbixDatasource extends DataSourceApi { + const disableDataAlignment = this.disableDataAlignment || target.options?.disableDataAlignment; + return !disableDataAlignment ? this.alignTimeSeriesData(timeseries) : timeseries; + }); } return getHistoryPromise @@ -253,6 +276,14 @@ export class ZabbixDatasource extends DataSourceApi { return this.zabbix.getHistoryText(items, timeRange, target); + }) + .then(result => { + if (target.resultFormat !== 'table') { + return result.map(s => responseHandler.seriesToDataFrame(s, target, [], FieldType.string)); + } + return result; }); } @@ -337,6 +374,9 @@ export class ZabbixDatasource extends DataSourceApi { return this.queryNumericDataForItems(items, target, timeRange, useTrends, options); + }) + .then(result => { + return result.map(s => responseHandler.seriesToDataFrame(s, target)); }); } @@ -367,7 +407,11 @@ export class ZabbixDatasource extends DataSourceApi this.applyDataProcessingFunctions(itservicesdp, target)); + .then(itservicesdp => this.applyDataProcessingFunctions(itservicesdp, target)) + .then(result => { + const dataFrames = result.map(s => responseHandler.seriesToDataFrame(s, target)); + return dataFrames; + }); } queryTriggersData(target, timeRange) { @@ -665,7 +709,10 @@ export class ZabbixDatasource extends DataSourceApi { func.params = _.map(func.params, param => { diff --git a/src/datasource-zabbix/partials/query.editor.html b/src/datasource-zabbix/partials/query.editor.html index 55809d9d7..f01affb30 100644 --- a/src/datasource-zabbix/partials/query.editor.html +++ b/src/datasource-zabbix/partials/query.editor.html @@ -282,19 +282,27 @@
-
- - +
+ + + +
-
- - + +
+
+ + +
diff --git a/src/datasource-zabbix/problemsHandler.ts b/src/datasource-zabbix/problemsHandler.ts index b04ac1aab..4fcb37e0e 100644 --- a/src/datasource-zabbix/problemsHandler.ts +++ b/src/datasource-zabbix/problemsHandler.ts @@ -171,7 +171,11 @@ export function toDataFrame(problems: any[]): DataFrame { name: 'Problems', type: FieldType.other, values: new ArrayVector(problems), - config: {}, + config: { + custom: { + type: 'problems', + }, + }, }; const response: DataFrame = { diff --git a/src/datasource-zabbix/query.controller.ts b/src/datasource-zabbix/query.controller.ts index 9914871a3..a8c1b5754 100644 --- a/src/datasource-zabbix/query.controller.ts +++ b/src/datasource-zabbix/query.controller.ts @@ -27,6 +27,7 @@ function getTargetDefaults() { options: { showDisabledItems: false, skipEmptyValues: false, + disableDataAlignment: false, }, table: { 'skipEmptyValues': false @@ -455,6 +456,7 @@ export class ZabbixQueryController extends QueryCtrl { renderQueryOptionsText() { const metricOptionsMap = { showDisabledItems: "Show disabled items", + disableDataAlignment: "Disable data alignment", }; const problemsOptionsMap = { diff --git a/src/datasource-zabbix/responseHandler.ts b/src/datasource-zabbix/responseHandler.ts index cb46c6579..f0d9ec6c6 100644 --- a/src/datasource-zabbix/responseHandler.ts +++ b/src/datasource-zabbix/responseHandler.ts @@ -1,6 +1,8 @@ import _ from 'lodash'; import TableModel from 'grafana/app/core/table_model'; import * as c from './constants'; +import * as utils from './utils'; +import { ArrayVector, DataFrame, DataQuery, Field, FieldType, MutableDataFrame, TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME } from '@grafana/data'; /** * Convert Zabbix API history.get response to Grafana format @@ -35,6 +37,7 @@ function convertHistory(history, items, addHostName, convertPointCallback) { '__zbx_item': { value: item.name }, '__zbx_item_name': { value: item.name }, '__zbx_item_key': { value: item.key_ }, + '__zbx_item_interval': { value: item.delay }, }; if (_.keys(hosts).length > 0) { @@ -52,10 +55,184 @@ function convertHistory(history, items, addHostName, convertPointCallback) { target: alias, datapoints: _.map(hist, convertPointCallback), scopedVars, + item }; }); } +export function seriesToDataFrame(timeseries, target: DataQuery, valueMappings?: any[], fieldType?: FieldType): DataFrame { + const { datapoints, scopedVars, target: seriesName, item } = timeseries; + + const timeFiled: Field = { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: { + custom: {} + }, + values: new ArrayVector(datapoints.map(p => p[c.DATAPOINT_TS])), + }; + + let values: ArrayVector | ArrayVector; + if (fieldType === FieldType.string) { + values = new ArrayVector(datapoints.map(p => p[c.DATAPOINT_VALUE])); + } else { + values = new ArrayVector(datapoints.map(p => p[c.DATAPOINT_VALUE])); + } + + const valueFiled: Field = { + name: TIME_SERIES_VALUE_FIELD_NAME, + type: fieldType ?? FieldType.number, + labels: {}, + config: { + displayName: seriesName, + displayNameFromDS: seriesName, + custom: {} + }, + values, + }; + + if (scopedVars) { + timeFiled.config.custom = { + itemInterval: scopedVars['__zbx_item_interval']?.value, + }; + + valueFiled.labels = { + host: scopedVars['__zbx_host_name']?.value, + item: scopedVars['__zbx_item']?.value, + item_key: scopedVars['__zbx_item_key']?.value, + }; + + valueFiled.config.custom = { + itemInterval: scopedVars['__zbx_item_interval']?.value, + }; + } + + if (item) { + // Try to use unit configured in Zabbix + const unit = utils.convertZabbixUnit(item.units); + if (unit) { + console.log(`Datasource: unit detected: ${unit} (${item.units})`); + valueFiled.config.unit = unit; + + if (unit === 'percent') { + valueFiled.config.min = 0; + valueFiled.config.max = 100; + } + } + + // Try to use value mapping from Zabbix + const mappings = utils.getValueMapping(item, valueMappings); + if (mappings) { + console.log(`Datasource: value mapping detected`); + valueFiled.config.mappings = mappings; + } + } + + const fields: Field[] = [ timeFiled, valueFiled ]; + + const frame: DataFrame = { + name: seriesName, + refId: target.refId, + fields, + length: datapoints.length, + }; + + return frame; +} + +export function isConvertibleToWide(data: DataFrame[]): boolean { + if (!data || data.length < 2) { + return false; + } + + const first = data[0].fields.find(f => f.type === FieldType.time); + if (!first) { + return false; + } + + for (let i = 1; i < data.length; i++) { + const timeField = data[i].fields.find(f => f.type === FieldType.time); + + for (let j = 0; j < Math.min(data.length, 2); j++) { + if (timeField.values.get(j) !== first.values.get(j)) { + return false; + } + } + } + + return true; +} + +export function alignFrames(data: DataFrame[]): DataFrame[] { + if (!data || data.length === 0) { + return data; + } + + // Get oldest time stamp for all frames + let minTimestamp = data[0].fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME).values.get(0); + for (let i = 0; i < data.length; i++) { + const timeField = data[i].fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME); + const firstTs = timeField.values.get(0); + if (firstTs < minTimestamp) { + minTimestamp = firstTs; + } + } + + for (let i = 0; i < data.length; i++) { + const frame = data[i]; + const timeField = frame.fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME); + const valueField = frame.fields.find(f => f.name === TIME_SERIES_VALUE_FIELD_NAME); + const firstTs = timeField.values.get(0); + + if (firstTs > minTimestamp) { + console.log('Data frames: adding missing points'); + let timestamps = timeField.values.toArray(); + let values = valueField.values.toArray(); + const missingTimestamps = []; + const missingValues = []; + const frameInterval: number = timeField.config.custom?.itemInterval; + for (let j = minTimestamp; j < firstTs; j+=frameInterval) { + missingTimestamps.push(j); + missingValues.push(null); + } + + timestamps = missingTimestamps.concat(timestamps); + values = missingValues.concat(values); + timeField.values = new ArrayVector(timestamps); + valueField.values = new ArrayVector(values); + } + } + + return data; +} + +export function convertToWide(data: DataFrame[]): DataFrame[] { + const timeField = data[0].fields.find(f => f.type === FieldType.time); + if (!timeField) { + return []; + } + + const fields: Field[] = [ timeField ]; + + for (let i = 0; i < data.length; i++) { + const valueField = data[i].fields.find(f => f.name === TIME_SERIES_VALUE_FIELD_NAME); + if (!valueField) { + continue; + } + + valueField.name = data[i].name; + fields.push(valueField); + } + + const frame: DataFrame = { + name: "wide", + fields, + length: timeField.values.length, + }; + + return [frame]; +} + function sortTimeseries(timeseries) { // Sort trend data, issue #202 _.forEach(timeseries, series => { @@ -256,5 +433,9 @@ export default { handleHistoryAsTable, handleSLAResponse, handleTriggersResponse, - sortTimeseries + sortTimeseries, + seriesToDataFrame, + isConvertibleToWide, + convertToWide, + alignFrames, }; diff --git a/src/datasource-zabbix/timeseries.ts b/src/datasource-zabbix/timeseries.ts index c40ee91f3..40f062349 100644 --- a/src/datasource-zabbix/timeseries.ts +++ b/src/datasource-zabbix/timeseries.ts @@ -12,6 +12,7 @@ import _ from 'lodash'; import * as utils from './utils'; import * as c from './constants'; +import { TimeSeriesPoints, TimeSeriesValue } from '@grafana/data'; const POINT_VALUE = 0; const POINT_TIMESTAMP = 1; @@ -62,6 +63,61 @@ function downsample(datapoints, time_to, ms_interval, func) { return downsampledSeries.reverse(); } +/** + * Detects interval between data points and aligns time series. If there's no value in the interval, puts null as a value. + */ +export function align(datapoints: TimeSeriesPoints, interval?: number): TimeSeriesPoints { + if (interval) { + interval = detectSeriesInterval(datapoints); + } + + if (interval <= 0 || datapoints.length <= 1) { + return datapoints; + } + + const aligned_ts: TimeSeriesPoints = []; + let frame_ts = getPointTimeFrame(datapoints[0][POINT_TIMESTAMP], interval); + let point_frame_ts = frame_ts; + let point: TimeSeriesValue[]; + for (let i = 0; i < datapoints.length; i++) { + point = datapoints[i]; + point_frame_ts = getPointTimeFrame(point[POINT_TIMESTAMP], interval); + + if (point_frame_ts > frame_ts) { + // Move frame window to next non-empty interval and fill empty by null + while (frame_ts < point_frame_ts) { + aligned_ts.push([null, frame_ts]); + frame_ts += interval; + } + } + + aligned_ts.push([point[POINT_VALUE], point_frame_ts]); + frame_ts += interval; + } + return aligned_ts; +} + +/** + * Detects interval between data points in milliseconds. + */ +function detectSeriesInterval(datapoints: TimeSeriesPoints): number { + if (datapoints.length < 2) { + return -1; + } + + let deltas = []; + for (let i = 1; i < datapoints.length; i++) { + // Get deltas (in seconds) + const d = (datapoints[i][POINT_TIMESTAMP] - datapoints[i - 1][POINT_TIMESTAMP]) / 1000; + deltas.push(Math.round(d)); + } + + // Use 50th percentile (median) as an interval + deltas = _.sortBy(deltas); + const intervalSec = deltas[Math.floor(deltas.length * 0.5)]; + return intervalSec * 1000; +} + /** * Group points by given time interval * datapoints: [[, ], ...] @@ -255,7 +311,10 @@ function rate(datapoints) { return newSeries; } -function simpleMovingAverage(datapoints, n) { +function simpleMovingAverage(datapoints: TimeSeriesPoints, n: number): TimeSeriesPoints { + // It's not possible to calculate MA if n greater than number of points + n = Math.min(n, datapoints.length); + const sma = []; let w_sum; let w_avg = null; @@ -298,7 +357,10 @@ function simpleMovingAverage(datapoints, n) { return sma; } -function expMovingAverage(datapoints, n) { +function expMovingAverage(datapoints: TimeSeriesPoints, n: number): TimeSeriesPoints { + // It's not possible to calculate MA if n greater than number of points + n = Math.min(n, datapoints.length); + let ema = [datapoints[0]]; let ema_prev = datapoints[0][POINT_VALUE]; let ema_cur; @@ -526,6 +588,7 @@ const exportedFunctions = { PERCENTILE, sortByTime, flattenDatapoints, + align, }; export default exportedFunctions; diff --git a/src/datasource-zabbix/types.ts b/src/datasource-zabbix/types.ts index a39f134d0..fe1b438b1 100644 --- a/src/datasource-zabbix/types.ts +++ b/src/datasource-zabbix/types.ts @@ -13,6 +13,7 @@ export interface ZabbixDSOptions extends DataSourceJsonData { dbConnectionDatasourceName?: string; dbConnectionRetentionPolicy?: string; disableReadOnlyUsersAck: boolean; + disableDataAlignment: boolean; } export interface ZabbixSecureJSONData { @@ -37,7 +38,7 @@ export interface ZabbixMetricsQuery extends DataQuery { queryType: string; datasourceId: number; functions: ZabbixMetricFunction[]; - options: any; + options: ZabbixQueryOptions; textFilter: string; mode: number; itemids: number[]; @@ -50,6 +51,19 @@ export interface ZabbixMetricsQuery extends DataQuery { itemFilter: string; } +export interface ZabbixQueryOptions { + showDisabledItems?: boolean; + skipEmptyValues?: boolean; + disableDataAlignment?: boolean; + // Problems options + minSeverity?: number; + sortProblems?: string; + acknowledged?: number; + hostsInMaintenance?: boolean; + hostProxy?: boolean; + limit?: number; +} + export interface ZabbixMetricFunction { name: string; params: any; diff --git a/src/datasource-zabbix/utils.ts b/src/datasource-zabbix/utils.ts index 03f1c29fc..05ed03f89 100644 --- a/src/datasource-zabbix/utils.ts +++ b/src/datasource-zabbix/utils.ts @@ -3,6 +3,7 @@ import moment from 'moment'; import kbn from 'grafana/app/core/utils/kbn'; import * as c from './constants'; import { VariableQuery, VariableQueryTypes } from './types'; +import { arrowTableToDataFrame, isTableData, MappingType, ValueMap, ValueMapping, getValueFormats, DataFrame, FieldType } from '@grafana/data'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -235,6 +236,26 @@ export function escapeRegex(value) { return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'); } +/** + * Parses Zabbix item update interval. Returns 0 in case of custom intervals. + */ +export function parseItemInterval(interval: string): number { + const normalizedInterval = normalizeZabbixInterval(interval); + if (normalizedInterval) { + return parseInterval(normalizedInterval); + } + return 0; +} + +export function normalizeZabbixInterval(interval: string): string { + const intervalPattern = /(^[\d]+)(y|M|w|d|h|m|s)?/g; + const parsedInterval = intervalPattern.exec(interval); + if (!parsedInterval) { + return ''; + } + return parsedInterval[1] + (parsedInterval.length > 2 ? parsedInterval[2] : 's'); +} + export function parseInterval(interval: string): number { const intervalPattern = /(^[\d]+)(y|M|w|d|h|m|s)/g; const momentInterval: any[] = intervalPattern.exec(interval); @@ -387,3 +408,65 @@ export function parseTags(tagStr: string): any[] { export function mustArray(result: any): any[] { return result || []; } + +const getUnitsMap = () => ({ + '%': 'percent', + 'b': 'decbits', // bits(SI) + 'bps': 'bps', // bits/sec(SI) + 'B': 'bytes', // bytes(IEC) + 'Bps': 'binBps', // bytes/sec(IEC) + // 'unixtime': 'dateTimeAsSystem', + 'uptime': 'dtdhms', + 'qps': 'qps', // requests/sec (rps) + 'iops': 'iops', // I/O ops/sec (iops) + 'Hz': 'hertz', // Hertz (1/s) + 'V': 'volt', // Volt (V) + 'C': 'celsius', // Celsius (°C) + 'RPM': 'rotrpm', // Revolutions per minute (rpm) + 'dBm': 'dBm', // Decibel-milliwatt (dBm) +}); + +const getKnownGrafanaUnits = () => { + const units = {}; + const categories = getValueFormats(); + for (const category of categories) { + for (const unitDesc of category.submenu) { + const unit = unitDesc.value; + units[unit] = unit; + } + } + return units; +}; + +const unitsMap = getUnitsMap(); +const knownGrafanaUnits = getKnownGrafanaUnits(); + +export function convertZabbixUnit(zabbixUnit: string): string { + let unit = unitsMap[zabbixUnit]; + if (!unit) { + unit = knownGrafanaUnits[zabbixUnit]; + } + return unit; +} + +export function getValueMapping(item, valueMappings: any[]): ValueMapping[] | null { + const { valuemapid } = item; + const mapping = valueMappings.find(m => m.valuemapid === valuemapid); + if (!mapping) { + return null; + } + + return (mapping.mappings as any[]).map((m, i) => { + const valueMapping: ValueMapping = { + id: i, + type: MappingType.ValueToText, + value: m.value, + text: m.newvalue, + }; + return valueMapping; + }); +} + +export function isProblemsDataFrame(data: DataFrame): boolean { + return data.fields.length && data.fields[0].type === FieldType.other && data.fields[0].config.custom['type'] === 'problems'; +} diff --git a/src/datasource-zabbix/zabbix/connectors/dbConnector.js b/src/datasource-zabbix/zabbix/connectors/dbConnector.js index e118aab88..a2f75c1a3 100644 --- a/src/datasource-zabbix/zabbix/connectors/dbConnector.js +++ b/src/datasource-zabbix/zabbix/connectors/dbConnector.js @@ -134,6 +134,7 @@ function convertGrafanaTSResponse(time_series, items, addHostName) { '__zbx_item': { value: item.name }, '__zbx_item_name': { value: item.name }, '__zbx_item_key': { value: item.key_ }, + '__zbx_item_interval': { value: item.delay }, }; if (_.keys(hosts).length > 0) { @@ -153,6 +154,7 @@ function convertGrafanaTSResponse(time_series, items, addHostName) { target: alias, datapoints, scopedVars, + item }; }); diff --git a/src/datasource-zabbix/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts b/src/datasource-zabbix/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts index 30311c267..b197f92fe 100644 --- a/src/datasource-zabbix/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts +++ b/src/datasource-zabbix/zabbix/connectors/zabbix_api/zabbixAPIConnector.ts @@ -161,11 +161,15 @@ export class ZabbixAPIConnector { getItems(hostids, appids, itemtype) { const params: any = { output: [ - 'name', 'key_', + 'name', + 'key_', 'value_type', 'hostid', 'status', - 'state' + 'state', + 'units', + 'valuemapid', + 'delay' ], sortfield: 'name', webitems: true, @@ -651,6 +655,15 @@ export class ZabbixAPIConnector { return this.request('script.execute', params); } + + getValueMappings() { + const params = { + output: 'extend', + selectMappings: "extend", + }; + + return this.request('valuemap.get', params); + } } function filterTriggersByAcknowledge(triggers, acknowledged) { diff --git a/src/datasource-zabbix/zabbix/zabbix.ts b/src/datasource-zabbix/zabbix/zabbix.ts index 0f7d0e4d0..00e86aa39 100644 --- a/src/datasource-zabbix/zabbix/zabbix.ts +++ b/src/datasource-zabbix/zabbix/zabbix.ts @@ -20,17 +20,17 @@ interface AppsResponse extends Array { const REQUESTS_TO_PROXYFY = [ 'getHistory', 'getTrend', 'getGroups', 'getHosts', 'getApps', 'getItems', 'getMacros', 'getItemsByIDs', 'getEvents', 'getAlerts', 'getHostAlerts', 'getAcknowledges', 'getITService', 'getSLA', 'getVersion', 'getProxies', - 'getEventAlerts', 'getExtendedEventData', 'getProblems', 'getEventsHistory', 'getTriggersByIds', 'getScripts' + 'getEventAlerts', 'getExtendedEventData', 'getProblems', 'getEventsHistory', 'getTriggersByIds', 'getScripts', 'getValueMappings' ]; const REQUESTS_TO_CACHE = [ - 'getGroups', 'getHosts', 'getApps', 'getItems', 'getMacros', 'getItemsByIDs', 'getITService', 'getProxies' + 'getGroups', 'getHosts', 'getApps', 'getItems', 'getMacros', 'getItemsByIDs', 'getITService', 'getProxies', 'getValueMappings' ]; const REQUESTS_TO_BIND = [ 'getHistory', 'getTrend', 'getMacros', 'getItemsByIDs', 'getEvents', 'getAlerts', 'getHostAlerts', 'getAcknowledges', 'getITService', 'getVersion', 'acknowledgeEvent', 'getProxies', 'getEventAlerts', - 'getExtendedEventData', 'getScripts', 'executeScript', + 'getExtendedEventData', 'getScripts', 'executeScript', 'getValueMappings' ]; export class Zabbix implements ZabbixConnector { @@ -55,6 +55,7 @@ export class Zabbix implements ZabbixConnector { getExtendedEventData: (eventids) => Promise; getMacros: (hostids: any[]) => Promise; getVersion: () => Promise; + getValueMappings: () => Promise; constructor(options) { const {