diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ed44e8f..d3ac8370d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#104](https://github.com/kobsio/kobs/pull/104): Add actions for Opsgenie plugin to acknowledge, snooze and close alerts. - [#105](https://github.com/kobsio/kobs/pull/105): Add Prometheus metrics for API requests. - [#112](https://github.com/kobsio/kobs/pull/112): Allow mapping values in Prometheus table panel. +- [#113](https://github.com/kobsio/kobs/pull/113): Allow and improve customization of axis scaling. ### Fixed diff --git a/docs/plugins/prometheus.md b/docs/plugins/prometheus.md index 4d5178ef1..2e70e6a8d 100644 --- a/docs/plugins/prometheus.md +++ b/docs/plugins/prometheus.md @@ -16,10 +16,20 @@ The following options can be used for a panel with the Prometheus plugin: | unit | string | An optional unit for the y axis of the chart. | No | | stacked | boolean | When this is `true` all time series in the chart will be stacked. | No | | legend | string | The type which should be used for the legend. Currently only `table` and `table-large` is supported as legend. If the value is not set, no legend will be shown. | No | +| yAxis | [yAxis](#yaxis) | Set the scale of the y axis. | No | | mappings | map | Specify value mappings for your data. **Note:** The value must be provided as string (e.g. `"1": "Green"`). | No | | queries | [[]Query](#query) | A list of queries, which are used to get the data for the chart. | Yes | | columns | [[]Column](#column) | A list of columns, which **must** be provided, when the type of the chart is `table` | No | +### yAxis + +The y axis can be customized for line and area charts. It is possible to use the min/max value of all returned time series or you can set a custom value. By default the scale of the y axis will be automatically determined. + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| min | `auto`, `min`, number | The minimum value for the y axis. This could be `auto`, `min` (minimum value accross all displayed metrics) or a custom number. The default is `auto`. | No | +| max | `auto`, `max`, number | The minimum value for the y axis. This could be `auto`, `max` (maximum value accross all displayed metrics) or a custom number. The default is `auto`. | No | + ### Query | Field | Type | Description | Required | diff --git a/plugins/prometheus/pkg/instance/instance.go b/plugins/prometheus/pkg/instance/instance.go index ea2477d0a..8c0ad7cf8 100644 --- a/plugins/prometheus/pkg/instance/instance.go +++ b/plugins/prometheus/pkg/instance/instance.go @@ -64,7 +64,7 @@ func (i *Instance) GetVariable(ctx context.Context, label, query, queryType stri // GetMetrics returns all metrics for all given queries. For each given query we have to make one call to the Prometheus // API. Then we have to loop through the returned time series and transform them into a format, which can be processed // by out React UI. -func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution string, timeStart, timeEnd int64) ([]Metric, error) { +func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution string, timeStart, timeEnd int64) (*Metrics, error) { steps := getSteps(timeStart, timeEnd) if resolution != "" { parsedDuration, err := time.ParseDuration(resolution) @@ -79,6 +79,11 @@ func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution s Step: steps, } + globalTimeStart := timeStart * 1000 + globalTimeEnd := timeEnd * 1000 + + var globalMin float64 + var globalMax float64 var metrics []Metric for queryIndex, query := range queries { @@ -105,11 +110,32 @@ func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution s timestamp := value.Timestamp.Unix() * 1000 val := float64(value.Value) + // Determine the start and end time accross all series. In the React UI this is used to define the min + // and max value for x axis of the chart. + if timestamp < globalTimeStart { + globalTimeStart = timestamp + } else if timestamp > globalTimeEnd { + globalTimeEnd = timestamp + } + if math.IsNaN(val) { data = append(data, Datum{ X: timestamp, }) } else { + // Determine the min and max value accross all series. In the React UI this is used to define the + // min and max value for the y axis of the chart. + if queryIndex == 0 && streamIndex == 0 && index == 0 { + globalMin = val + globalMax = val + } else { + if val < globalMin { + globalMin = val + } else if val > globalMax { + globalMax = val + } + } + avg = avg + val count = count + 1 @@ -169,7 +195,13 @@ func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution s } } - return metrics, nil + return &Metrics{ + StartTime: globalTimeStart, + EndTime: globalTimeEnd, + Min: globalMin, + Max: globalMax, + Metrics: metrics, + }, nil } // GetTableData returns the data, when the user selected the table view for the Prometheus plugin. To get the data we diff --git a/plugins/prometheus/pkg/instance/structs.go b/plugins/prometheus/pkg/instance/structs.go index c0d545463..b755ab7df 100644 --- a/plugins/prometheus/pkg/instance/structs.go +++ b/plugins/prometheus/pkg/instance/structs.go @@ -7,6 +7,16 @@ type Query struct { Label string `json:"label"` } +// Metrics is the structure for the returned metrics from the Prometheus metrics API endpoint. It contains a list of +// metrics, the start and end time for the query and the min and max value accross all time series. +type Metrics struct { + StartTime int64 `json:"startTime"` + EndTime int64 `json:"endTime"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Metrics []Metric `json:"metrics"` +} + // Metric is the response format for a single metric. Each metric must have an ID and label. We also add the min, max // and average for the returned data. type Metric struct { diff --git a/plugins/prometheus/prometheus.go b/plugins/prometheus/prometheus.go index f41c26294..e43bbb898 100644 --- a/plugins/prometheus/prometheus.go +++ b/plugins/prometheus/prometheus.go @@ -121,7 +121,7 @@ func (router *Router) getMetrics(w http.ResponseWriter, r *http.Request) { return } - log.WithFields(logrus.Fields{"metrics": len(metrics)}).Tracef("getMetrics") + log.WithFields(logrus.Fields{"metrics": len(metrics.Metrics)}).Tracef("getMetrics") render.JSON(w, r, metrics) } diff --git a/plugins/prometheus/src/components/page/PageChart.tsx b/plugins/prometheus/src/components/page/PageChart.tsx index 6427b76a8..d1038debe 100644 --- a/plugins/prometheus/src/components/page/PageChart.tsx +++ b/plugins/prometheus/src/components/page/PageChart.tsx @@ -6,24 +6,22 @@ import { TooltipWrapper } from '@nivo/tooltip'; import { convertMetrics, formatAxisBottom } from '../../utils/helpers'; import { COLOR_SCALE } from '../../utils/colors'; -import { IMetric } from '../../utils/interfaces'; -import { IPluginTimes } from '@kobsio/plugin-core'; +import { IMetrics } from '../../utils/interfaces'; import PageChartLegend from './PageChartLegend'; interface IPageChartProps { queries: string[]; - times: IPluginTimes; - metrics: IMetric[]; + metrics: IMetrics; } // The PageChart component is used to render the chart for the given metrics. Above the chart we display two toggle // groups so that the user can adjust the basic style of the chart. The user can switch between a line and area chart // and between a stacked and unstacked visualization. At the bottom of the page we are including the PageChartLegend // component to render the legend for the metrics. -const PageChart: React.FunctionComponent = ({ queries, times, metrics }: IPageChartProps) => { +const PageChart: React.FunctionComponent = ({ queries, metrics }: IPageChartProps) => { // series is an array for the converted metrics, which can be used by nivo. We convert the metrics to a series, so // that we have to do this onyl once and not everytime the selected metrics are changed. - const seriesData = convertMetrics(metrics); + const seriesData = convertMetrics(metrics.metrics, metrics.startTime, metrics.endTime, metrics.min, metrics.max); const [type, setType] = useState('line'); const [stacked, setStacked] = useState(false); @@ -60,7 +58,7 @@ const PageChart: React.FunctionComponent = ({ queries, times, m
-.2f', @@ -86,8 +84,7 @@ const PageChart: React.FunctionComponent = ({ queries, times, m }} // eslint-disable-next-line @typescript-eslint/explicit-function-return-type tooltip={(tooltip) => { - const isFirstHalf = - Math.floor(new Date(tooltip.point.data.x).getTime() / 1000) < (times.timeEnd + times.timeStart) / 2; + const isFirstHalf = new Date(tooltip.point.data.x).getTime() < (metrics.endTime + metrics.startTime) / 2; return ( @@ -111,7 +108,7 @@ const PageChart: React.FunctionComponent = ({ queries, times, m ); }} - xScale={{ max: new Date(times.timeEnd * 1000), min: new Date(times.timeStart * 1000), type: 'time' }} + xScale={{ max: new Date(metrics.endTime), min: new Date(metrics.startTime), type: 'time' }} yScale={{ stacked: stacked, type: 'linear' }} yFormat=" >-.4f" /> diff --git a/plugins/prometheus/src/components/page/PageChartWrapper.tsx b/plugins/prometheus/src/components/page/PageChartWrapper.tsx index 8411e6825..815daf027 100644 --- a/plugins/prometheus/src/components/page/PageChartWrapper.tsx +++ b/plugins/prometheus/src/components/page/PageChartWrapper.tsx @@ -3,7 +3,7 @@ import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { IMetric, IOptions } from '../../utils/interfaces'; +import { IMetrics, IOptions } from '../../utils/interfaces'; import PageChart from './PageChart'; interface IPageChartWrapperProps extends IOptions { @@ -21,7 +21,7 @@ const PageChartWrapper: React.FunctionComponent = ({ }: IPageChartWrapperProps) => { const history = useHistory(); - const { isError, isLoading, error, data, refetch } = useQuery( + const { isError, isLoading, error, data, refetch } = useQuery( ['prometheus/metrics', name, queries, resolution, times], async () => { try { @@ -69,7 +69,7 @@ const PageChartWrapper: React.FunctionComponent = ({ actionLinks={ history.push('/')}>Home - > => refetch()}> + > => refetch()}> Retry @@ -84,7 +84,7 @@ const PageChartWrapper: React.FunctionComponent = ({ return null; } - return ; + return ; }; export default PageChartWrapper; diff --git a/plugins/prometheus/src/components/panel/Chart.tsx b/plugins/prometheus/src/components/panel/Chart.tsx index 68f6344df..bf7612fbb 100644 --- a/plugins/prometheus/src/components/panel/Chart.tsx +++ b/plugins/prometheus/src/components/panel/Chart.tsx @@ -1,15 +1,39 @@ import { ResponsiveLineCanvas, Serie } from '@nivo/line'; import React from 'react'; +import { ScaleSpec } from '@nivo/scales'; import { SquareIcon } from '@patternfly/react-icons'; import { TooltipWrapper } from '@nivo/tooltip'; -import { ILabels, IPanelOptions } from '../../utils/interfaces'; +import { ILabels, IPanelOptions, IYAxis } from '../../utils/interfaces'; import { COLOR_SCALE } from '../../utils/colors'; -import { IPluginTimes } from '@kobsio/plugin-core'; import { formatAxisBottom } from '../../utils/helpers'; +const getYScale = (yAxis: IYAxis | undefined, stacked: boolean | undefined, min: number, max: number): ScaleSpec => { + let minValue: number | 'auto' = 'auto'; + let maxValue: number | 'auto' = 'auto'; + + if (yAxis) { + if (yAxis.min && yAxis.min === 'min') { + minValue = min; + } else if (yAxis.min && typeof yAxis.min === 'number') { + minValue = yAxis.min; + } + + if (yAxis.max && yAxis.max === 'max') { + maxValue = max; + } else if (yAxis.max && typeof yAxis.max === 'number') { + maxValue = yAxis.max; + } + } + + return { max: maxValue, min: minValue, stacked: stacked, type: 'linear' }; +}; + interface IChartProps { - times: IPluginTimes; + startTime: number; + endTime: number; + min: number; + max: number; options: IPanelOptions; labels: ILabels; series: Serie[]; @@ -17,11 +41,19 @@ interface IChartProps { // The Chart component is responsible for rendering the chart for all the metrics, which were returned for the users // specified queries. -export const Chart: React.FunctionComponent = ({ times, options, labels, series }: IChartProps) => { +export const Chart: React.FunctionComponent = ({ + startTime, + endTime, + min, + max, + options, + labels, + series, +}: IChartProps) => { return ( -.2f', @@ -47,8 +79,7 @@ export const Chart: React.FunctionComponent = ({ times, options, la }} // eslint-disable-next-line @typescript-eslint/explicit-function-return-type tooltip={(tooltip) => { - const isFirstHalf = - Math.floor(new Date(tooltip.point.data.x).getTime() / 1000) < (times.timeEnd + times.timeStart) / 2; + const isFirstHalf = new Date(tooltip.point.data.x).getTime() < (endTime + startTime) / 2; return ( @@ -72,8 +103,8 @@ export const Chart: React.FunctionComponent = ({ times, options, la ); }} - xScale={{ max: new Date(times.timeEnd * 1000), min: new Date(times.timeStart * 1000), type: 'time' }} - yScale={{ max: 'auto', min: 'auto', stacked: options.stacked, type: 'linear' }} + xScale={{ max: new Date(endTime), min: new Date(startTime), type: 'time' }} + yScale={getYScale(options.yAxis, options.stacked, min, max)} yFormat=" >-.4f" /> ); diff --git a/plugins/prometheus/src/components/panel/ChartWrapper.tsx b/plugins/prometheus/src/components/panel/ChartWrapper.tsx index 9c1ece960..c44af6b00 100644 --- a/plugins/prometheus/src/components/panel/ChartWrapper.tsx +++ b/plugins/prometheus/src/components/panel/ChartWrapper.tsx @@ -55,7 +55,7 @@ export const ChartWrapper: React.FunctionComponent = ({ const json = await response.json(); if (response.status >= 200 && response.status < 300) { - return convertMetrics(json); + return convertMetrics(json.metrics, json.startTime, json.endTime, json.min, json.max); } else { if (json.error) { throw new Error(json.error); @@ -114,7 +114,10 @@ export const ChartWrapper: React.FunctionComponent = ({ }} > 0 ? selectedSeries : data.series} diff --git a/plugins/prometheus/src/components/panel/Sparkline.tsx b/plugins/prometheus/src/components/panel/Sparkline.tsx index 6427880ce..bcced72e0 100644 --- a/plugins/prometheus/src/components/panel/Sparkline.tsx +++ b/plugins/prometheus/src/components/panel/Sparkline.tsx @@ -48,10 +48,10 @@ export const Spakrline: React.FunctionComponent = ({ const json = await response.json(); if (response.status >= 200 && response.status < 300) { - if (json) { - return convertMetrics(json); + if (json && json.metrics) { + return convertMetrics(json.metrics, json.startTime, json.endTime, json.min, json.max); } else { - return { labels: {}, series: [] }; + return { endTime: times.timeEnd, labels: {}, max: 0, min: 0, series: [], startTime: times.timeStart }; } } else { if (json.error) { @@ -125,7 +125,7 @@ export const Spakrline: React.FunctionComponent = ({ isInteractive={false} lineWidth={1} margin={{ bottom: 0, left: 0, right: 0, top: 0 }} - xScale={{ type: 'time' }} + xScale={{ max: new Date(data.endTime), min: new Date(data.startTime), type: 'time' }} yScale={{ stacked: false, type: 'linear' }} />
diff --git a/plugins/prometheus/src/components/preview/Sparkline.tsx b/plugins/prometheus/src/components/preview/Sparkline.tsx index db3c05094..d278c6f31 100644 --- a/plugins/prometheus/src/components/preview/Sparkline.tsx +++ b/plugins/prometheus/src/components/preview/Sparkline.tsx @@ -41,10 +41,10 @@ export const Spakrline: React.FunctionComponent = ({ const json = await response.json(); if (response.status >= 200 && response.status < 300) { - if (json) { - return convertMetrics(json); + if (json && json.metrics) { + return convertMetrics(json.metrics, json.startTime, json.endTime, json.min, json.max); } else { - return { labels: {}, series: [] }; + return { endTime: times.timeEnd, labels: {}, max: 0, min: 0, series: [], startTime: times.timeStart }; } } else { if (json.error) { @@ -104,7 +104,7 @@ export const Spakrline: React.FunctionComponent = ({ isInteractive={false} lineWidth={1} margin={{ bottom: 0, left: 0, right: 0, top: 0 }} - xScale={{ type: 'time' }} + xScale={{ max: new Date(data.endTime), min: new Date(data.startTime), type: 'time' }} yScale={{ stacked: false, type: 'linear' }} /> diff --git a/plugins/prometheus/src/utils/helpers.ts b/plugins/prometheus/src/utils/helpers.ts index f6aabb0f8..ab6c497b5 100644 --- a/plugins/prometheus/src/utils/helpers.ts +++ b/plugins/prometheus/src/utils/helpers.ts @@ -1,7 +1,7 @@ import { DatumValue, Serie } from '@nivo/line'; import { ILabels, IMappings, IMetric, IOptions, ISeries } from './interfaces'; -import { IPluginTimes, TTime, TTimeOptions } from '@kobsio/plugin-core'; +import { TTime, TTimeOptions } from '@kobsio/plugin-core'; // getOptionsFromSearch is used to parse the given search location and return is as options for Prometheus. This is // needed, so that a user can explore his Prometheus data from a chart. When the user selects the explore action, we @@ -32,7 +32,15 @@ export const getOptionsFromSearch = (search: string): IOptions => { // convertMetrics converts a list of metrics, which is returned by our API into the format which is required by nivo. // This is necessary, because nivo requires a date object for the x value which can not be returned by our API. The API // instead returns the corresponding timestamp. -export const convertMetrics = (metrics: IMetric[]): ISeries => { +// The startTime and endTime and the min and max values are just passed to the returned ISeries so that we can use it in +// the UI, without maintaining two different objects. +export const convertMetrics = ( + metrics: IMetric[], + startTime: number, + endTime: number, + min: number, + max: number, +): ISeries => { const labels: ILabels = {}; const series: Serie[] = []; @@ -48,18 +56,25 @@ export const convertMetrics = (metrics: IMetric[]): ISeries => { } return { + endTime: endTime, labels: labels, + max: max, + min: min, series: series, + startTime: startTime, }; }; // formatAxisBottom calculates the format for the bottom axis based on the specified start and end time. -export const formatAxisBottom = (times: IPluginTimes): string => { - if (times.timeEnd - times.timeStart < 3600) { +export const formatAxisBottom = (timeStart: number, timeEnd: number): string => { + timeStart = Math.floor(timeStart / 1000); + timeEnd = Math.floor(timeEnd / 1000); + + if (timeEnd - timeStart < 3600) { return '%H:%M:%S'; - } else if (times.timeEnd - times.timeStart < 86400) { + } else if (timeEnd - timeStart < 86400) { return '%H:%M'; - } else if (times.timeEnd - times.timeStart < 604800) { + } else if (timeEnd - timeStart < 604800) { return '%m-%d %H:%M'; } diff --git a/plugins/prometheus/src/utils/interfaces.ts b/plugins/prometheus/src/utils/interfaces.ts index 813012d43..5fd1a605a 100644 --- a/plugins/prometheus/src/utils/interfaces.ts +++ b/plugins/prometheus/src/utils/interfaces.ts @@ -10,6 +10,16 @@ export interface IOptions { times: IPluginTimes; } +// IMetrics implements the interface for the corresponding Go struct, which is returned by our API. It contains a list +// of metrics, the start and end time and the minimum and maximum value accross all time series. +export interface IMetrics { + startTime: number; + endTime: number; + min: number; + max: number; + metrics: IMetric[]; +} + // IMetric implements the interface for the corresponding Go struct, which is returned by our API. It contains one // additional field named "color", which is used to specify the color for a line, when the user selected a single line // in the legend. The data points are implemented by the IDatum interface. @@ -47,7 +57,11 @@ export interface ILabels { // ISeries is the interface which is retunred by the convertMetrics function. It contains the converted series and all // labels for these series. export interface ISeries { + startTime: number; + endTime: number; labels: ILabels; + max: number; + min: number; series: Serie[]; } @@ -60,6 +74,7 @@ export interface IPanelOptions { unit?: string; stacked?: boolean; legend?: string; + yAxis?: IYAxis; mappings?: IMappings; queries?: IQuery[]; columns?: IColumn[]; @@ -80,3 +95,8 @@ export interface IColumn { unit?: string; mappings?: IMappings; } + +export interface IYAxis { + min: string | number; + max: string | number; +}