Skip to content

Commit

Permalink
Improve and allow customization of axis scaling (#113)
Browse files Browse the repository at this point in the history
It is now possible to customize the scaling of the y axis via the new
"yAxis" option for the Prometheus plugin. A user can set the minimum and
maximum value for the axis. A user can also specify two special string
values "auto" or "min" / "max" to automatically scale the axis or to use
the minimum / maximum value accross all loaded metrics.

We also improved the scale of the x axis by using the selected time
range or if there is a lower / higher value in the metrics we are using
this value instead, so that the lines / areas in a chart not rendered
outside of the specified range.
  • Loading branch information
ricoberger authored Aug 18, 2021
1 parent 8ba77dc commit ac78164
Show file tree
Hide file tree
Showing 13 changed files with 161 additions and 42 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/plugins/prometheus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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 |
Expand Down
36 changes: 34 additions & 2 deletions plugins/prometheus/pkg/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions plugins/prometheus/pkg/instance/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion plugins/prometheus/prometheus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
17 changes: 7 additions & 10 deletions plugins/prometheus/src/components/page/PageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPageChartProps> = ({ queries, times, metrics }: IPageChartProps) => {
const PageChart: React.FunctionComponent<IPageChartProps> = ({ 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<string>('line');
const [stacked, setStacked] = useState<boolean>(false);
Expand Down Expand Up @@ -60,7 +58,7 @@ const PageChart: React.FunctionComponent<IPageChartProps> = ({ queries, times, m
<div style={{ height: '350px' }}>
<ResponsiveLineCanvas
axisBottom={{
format: formatAxisBottom(times),
format: formatAxisBottom(metrics.startTime, metrics.endTime),
}}
axisLeft={{
format: '>-.2f',
Expand All @@ -86,8 +84,7 @@ const PageChart: React.FunctionComponent<IPageChartProps> = ({ 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 (
<TooltipWrapper anchor={isFirstHalf ? 'right' : 'left'} position={[0, 20]}>
Expand All @@ -111,7 +108,7 @@ const PageChart: React.FunctionComponent<IPageChartProps> = ({ queries, times, m
</TooltipWrapper>
);
}}
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"
/>
Expand Down
8 changes: 4 additions & 4 deletions plugins/prometheus/src/components/page/PageChartWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +21,7 @@ const PageChartWrapper: React.FunctionComponent<IPageChartWrapperProps> = ({
}: IPageChartWrapperProps) => {
const history = useHistory();

const { isError, isLoading, error, data, refetch } = useQuery<IMetric[], Error>(
const { isError, isLoading, error, data, refetch } = useQuery<IMetrics, Error>(
['prometheus/metrics', name, queries, resolution, times],
async () => {
try {
Expand Down Expand Up @@ -69,7 +69,7 @@ const PageChartWrapper: React.FunctionComponent<IPageChartWrapperProps> = ({
actionLinks={
<React.Fragment>
<AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink>
<AlertActionLink onClick={(): Promise<QueryObserverResult<IMetric[], Error>> => refetch()}>
<AlertActionLink onClick={(): Promise<QueryObserverResult<IMetrics, Error>> => refetch()}>
Retry
</AlertActionLink>
</React.Fragment>
Expand All @@ -84,7 +84,7 @@ const PageChartWrapper: React.FunctionComponent<IPageChartWrapperProps> = ({
return null;
}

return <PageChart queries={queries} times={times} metrics={data} />;
return <PageChart queries={queries} metrics={data} />;
};

export default PageChartWrapper;
49 changes: 40 additions & 9 deletions plugins/prometheus/src/components/panel/Chart.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,59 @@
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[];
}

// 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<IChartProps> = ({ times, options, labels, series }: IChartProps) => {
export const Chart: React.FunctionComponent<IChartProps> = ({
startTime,
endTime,
min,
max,
options,
labels,
series,
}: IChartProps) => {
return (
<ResponsiveLineCanvas
axisBottom={{
format: formatAxisBottom(times),
format: formatAxisBottom(startTime, endTime),
}}
axisLeft={{
format: '>-.2f',
Expand All @@ -47,8 +79,7 @@ export const Chart: React.FunctionComponent<IChartProps> = ({ 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 (
<TooltipWrapper anchor={isFirstHalf ? 'right' : 'left'} position={[0, 20]}>
Expand All @@ -72,8 +103,8 @@ export const Chart: React.FunctionComponent<IChartProps> = ({ times, options, la
</TooltipWrapper>
);
}}
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"
/>
);
Expand Down
7 changes: 5 additions & 2 deletions plugins/prometheus/src/components/panel/ChartWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const ChartWrapper: React.FunctionComponent<IChartWrapperProps> = ({
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);
Expand Down Expand Up @@ -114,7 +114,10 @@ export const ChartWrapper: React.FunctionComponent<IChartWrapperProps> = ({
}}
>
<Chart
times={times}
startTime={data.startTime}
endTime={data.endTime}
min={data.min}
max={data.max}
options={options}
labels={data.labels}
series={selectedSeries.length > 0 ? selectedSeries : data.series}
Expand Down
8 changes: 4 additions & 4 deletions plugins/prometheus/src/components/panel/Sparkline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export const Spakrline: React.FunctionComponent<ISpakrlineProps> = ({
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) {
Expand Down Expand Up @@ -125,7 +125,7 @@ export const Spakrline: React.FunctionComponent<ISpakrlineProps> = ({
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' }}
/>
</div>
Expand Down
8 changes: 4 additions & 4 deletions plugins/prometheus/src/components/preview/Sparkline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export const Spakrline: React.FunctionComponent<ISpakrlineProps> = ({
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) {
Expand Down Expand Up @@ -104,7 +104,7 @@ export const Spakrline: React.FunctionComponent<ISpakrlineProps> = ({
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' }}
/>
</div>
Expand Down
Loading

0 comments on commit ac78164

Please sign in to comment.