Browse Source

Merge branch 'pass-options-to-chartwerk-2' into 'main'

Pass options to chartwerk 2

See merge request chartwerk/grafana-chartwerk-panel!7
merge-requests/7/merge
Alexander Velikiy 2 years ago
parent
commit
d92d9887e7
  1. 12
      src/components/Panel.tsx
  2. 38
      src/components/editors/IconsEditor.tsx
  3. 2
      src/components/editors/NotSupportedText.tsx
  4. 8
      src/components/editors/ThresholdsEditor.tsx
  5. 13
      src/components/editors/UseMetricEditor.tsx
  6. 168
      src/models/options.ts
  7. 2
      src/models/series.ts
  8. 235
      src/module.ts
  9. 34
      src/types.ts
  10. 10
      src/utils.ts

12
src/components/Panel.tsx

@ -29,13 +29,25 @@ export function Panel({ options, data, width, height, timeZone, timeRange, onCha
const pod = new ChartwerkGaugePod((chartContainer as any).current, series, chartwerkOptions);
pod.render();
});
const isLinkActive = !_.isEmpty(options.gauge.link);
const chartClickHandler = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (!isLinkActive) {
return;
}
window.open(options.gauge.link, '_self');
};
return (
<div
ref={chartContainer}
className={css`
width: ${width}px;
height: ${height}px;
cursor: ${isLinkActive ? 'pointer' : 'default'};
`}
onClick={chartClickHandler}
></div>
);
}

38
src/components/editors/IconsEditor.tsx

@ -1,4 +1,4 @@
import { IconPosition } from 'types';
import { IconPosition, Icon, Condition } from 'types';
import { GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data';
import {
@ -18,23 +18,6 @@ import React from 'react';
import * as _ from 'lodash';
type IconConfig = {
position: IconPosition;
url: string;
metrics: string[];
conditions: Condition[];
values: number[];
size: number;
};
enum Condition {
EQUAL = '=',
GREATER = '>',
LESS = '<',
GREATER_OR_EQUAL = '>=',
LESS_OR_EQUAL = '<=',
}
const positionOptions: Array<SelectableValue<IconPosition>> = [
{
label: 'Upper-Left',
@ -73,7 +56,7 @@ const conditionOptions: Array<SelectableValue<Condition>> = [
},
];
const DEFAULT_ICON: IconConfig = {
const DEFAULT_ICON: Icon = {
position: IconPosition.UPPER_LEFT,
url: '',
size: 40,
@ -86,7 +69,7 @@ const fieldNamePickerSettings = {
settings: { width: 24 },
} as any;
export function IconsEditor({ onChange, value, context }: StandardEditorProps<IconConfig[]>) {
export function IconsEditor({ onChange, value, context }: StandardEditorProps<Icon[]>) {
const icons = value;
const addIcon = () => {
@ -113,14 +96,14 @@ export function IconsEditor({ onChange, value, context }: StandardEditorProps<Ic
onChange(icons);
};
const onIconFieldChange = (iconIdx: number, field: keyof IconConfig, value: any) => {
const onIconFieldChange = (iconIdx: number, field: keyof Icon, value: any) => {
// @ts-ignore
icons[iconIdx][field] = value;
onChange(icons);
};
const onConditionChange = (iconIdx: number, conditionIdx: number, field: keyof IconConfig, value: any) => {
const onConditionChange = (iconIdx: number, conditionIdx: number, field: keyof Icon, value: any) => {
// @ts-ignore
icons[iconIdx][field][conditionIdx] = value;
@ -137,11 +120,7 @@ export function IconsEditor({ onChange, value, context }: StandardEditorProps<Ic
{icons.map((icon, iconIdx) => {
return (
<div key={iconIdx} className={styles.icon}>
<IconButton
name="trash-alt"
onClick={() => removeIcon(iconIdx)}
tooltip="Delete Icon"
></IconButton>
<IconButton name="trash-alt" onClick={() => removeIcon(iconIdx)} tooltip="Delete Icon"></IconButton>
<div className={styles.row}>
<Input
type="url"
@ -167,8 +146,8 @@ export function IconsEditor({ onChange, value, context }: StandardEditorProps<Ic
{icon.conditions.map((condition, conditionIdx) => {
return (
<div className={styles.condition}>
<HorizontalGroup key={conditionIdx} >
<div key={conditionIdx} className={styles.condition}>
<HorizontalGroup key={conditionIdx}>
<FieldNamePicker
context={context}
value={icon.metrics[conditionIdx]}
@ -247,5 +226,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme): IconsEditorStyles => {
margin-bottom: ${theme.spacing.sm};
`,
};
});

2
src/components/editors/NotSupportedText.tsx

@ -1,5 +1,5 @@
import React from 'react';
export function NotSupportedText() {
return <div>To be supported soon...</div>
return <div>To be supported soon...</div>;
}

8
src/components/editors/ThresholdsEditor.tsx

@ -19,7 +19,6 @@ import React from 'react';
import { css } from 'emotion';
import * as _ from 'lodash';
interface Props {
defaultColor: string;
arcBackground: string;
@ -73,7 +72,7 @@ export function ThresholdsEditor({ onChange, value, context }: StandardEditorPro
const styles = getStyles(theme.v1);
return (
<div>
<InlineField label="Default Gauge Color">
<InlineField label="Default Gauge Color" labelWidth={20}>
<InlineLabel>
<ColorPicker
color={config.defaultColor}
@ -83,7 +82,7 @@ export function ThresholdsEditor({ onChange, value, context }: StandardEditorPro
</InlineLabel>
</InlineField>
<InlineField label="Arc Background">
<InlineField label="Arc Background" labelWidth={20}>
<InlineLabel>
<ColorPicker
color={config.arcBackground}
@ -112,6 +111,7 @@ export function ThresholdsEditor({ onChange, value, context }: StandardEditorPro
) : (
<Input
placeholder="value"
width={24}
value={threshold.value}
onChange={(evt) => onThresholdFieldChange(thresholdIdx, 'value', (evt.target as any).value)}
/>
@ -155,7 +155,7 @@ interface ThresholdsEditorStyles {
const getStyles = stylesFactory((theme: GrafanaTheme): ThresholdsEditorStyles => {
return {
deleteButton: css`
margin-right: 0px!important;
margin-right: 0px !important;
`,
};
});

13
src/components/editors/UseMetricEditor.tsx

@ -1,18 +1,12 @@
import { FieldNamePicker } from '../../grafana/MatchersUI/FieldNamePicker';
import { StandardEditorProps } from '@grafana/data';
import {
HorizontalGroup,
InlineField,
InlineSwitch,
Input,
} from '@grafana/ui';
import { HorizontalGroup, InlineField, InlineSwitch, Input } from '@grafana/ui';
import React from 'react';
import * as _ from 'lodash';
type UseMetricConfig = {
useMetric: boolean;
value?: number;
@ -31,7 +25,7 @@ export function UseMetricEditor({ onChange, value, context }: StandardEditorProp
config[field] = value;
onChange(config);
}
};
return (
<HorizontalGroup>
@ -53,10 +47,11 @@ export function UseMetricEditor({ onChange, value, context }: StandardEditorProp
<Input
placeholder="value"
value={config.value}
width={24}
onChange={(evt) => onFieldChange('value', (evt.target as any).value)}
/>
)}
</InlineField>
</HorizontalGroup>
)
);
}

168
src/models/options.ts

@ -1,69 +1,165 @@
import { PanelOptions, Aggregation } from 'types';
import { PanelOptions, Aggregation, Threshold, Icon, IconPosition, Condition } from 'types';
import { filterMetricListByAlias, getAggregatedValueFromSerie } from '../utils';
import { getValueFormat } from '@grafana/data';
import _ from 'lodash';
// Convert Grafana options into Chartwerk Gauge options
export class Options {
private minValue: number | undefined;
private maxValue: number | undefined;
private thresholds: Array<{ value: number; color: string }> = [];
private icons: Array<{ src: string; position: string; size: number }> = [];
constructor(private grafanaSeriesList: any[], private grafanaOptions: PanelOptions) {
this._setMin();
this._setMax();
this._setThresholds();
this._setIcons();
}
private _setMin(): any {
if (!this.grafanaOptions.gauge.min.metricName) {
private _setMin(): void {
if (!this.grafanaOptions.gauge.min.useMetric) {
this.minValue = this.grafanaOptions.gauge.min.value;
return;
}
const filteredSeries = filterMetricListByAlias(
this.grafanaSeriesList,
this.grafanaOptions.gauge.min.metricName,
'Min'
);
const serie = filteredSeries[0];
// Last value for now
const aggregatedValue = getAggregatedValueFromSerie(serie, Aggregation.LAST);
const aggregatedValue = this.getLastValueFromMetrics(this.grafanaOptions.gauge.min.metricName, 'Min');
this.minValue = aggregatedValue ? aggregatedValue : undefined;
}
private _setMax(): any {
if (!this.grafanaOptions.gauge.max.metricName) {
private _setMax(): void {
if (!this.grafanaOptions.gauge.max.useMetric) {
this.maxValue = this.grafanaOptions.gauge.max.value;
return;
}
const filteredSeries = filterMetricListByAlias(
this.grafanaSeriesList,
this.grafanaOptions.gauge.max.metricName,
'Max'
);
const serie = filteredSeries[0];
// Last value for now
const aggregatedValue = getAggregatedValueFromSerie(serie, Aggregation.LAST);
const aggregatedValue = this.getLastValueFromMetrics(this.grafanaOptions.gauge.max.metricName, 'Max');
this.maxValue = aggregatedValue ? aggregatedValue : undefined;
}
private _setThresholds(): void {
if (_.isEmpty(this.grafanaOptions.gauge.thresholds.thresholds)) {
return;
}
for (let [idx, threshold] of this.grafanaOptions.gauge.thresholds.thresholds.entries()) {
this._setThreshold(threshold, idx);
}
}
private _setThreshold(threshold: Threshold, idx: number): void {
const value = threshold.useMetric
? this.getLastValueFromMetrics(threshold.metricName, `Threshold ${idx + 1}`)
: threshold.value;
if (value === null || value === undefined) {
// TODO: may be throw an error
return;
}
this.thresholds.push({
value,
color: threshold.color,
});
}
private _valueFormatter(value: number): string {
const suffix = getValueFormat(this.grafanaOptions.gauge.unit)(0)?.suffix || '';
const decimals = _.isNumber(this.grafanaOptions.gauge.decimals) ? this.grafanaOptions.gauge.decimals : 2;
return `${value.toFixed(decimals)} ${suffix}`;
}
private _setIcons(): void {
if (_.isEmpty(this.grafanaOptions.gauge.icons)) {
return;
}
for (let [idx, icon] of this.grafanaOptions.gauge.icons.entries()) {
this._setIcon(icon, idx);
}
}
private _setIcon(icon: Icon, idx: number): void {
if (!this._areIconConditionsFulfilled(icon, idx)) {
return;
}
this.icons.push({
src: icon.url,
size: icon.size,
position: this._getChartwerkIconPosition(icon.position),
});
}
private _areIconConditionsFulfilled(icon: Icon, iconIdx: number): boolean {
if (_.isEmpty(icon.metrics)) {
return true;
}
// check each condition and return false if something goes wrong
for (let [conditionIdx, metric] of icon.metrics.entries()) {
const value = this.getLastValueFromMetrics(metric, `Icon ${iconIdx + 1}, Condition ${conditionIdx + 1}`);
if (value === null || value === undefined) {
// TODO: may be throw an error
return false;
}
if (!this.checkIconCondition(value, icon.values[conditionIdx], icon.conditions[conditionIdx])) {
return false;
}
}
return true;
}
private checkIconCondition(metricValue: number, inputValue: number, condition: Condition): boolean {
if (inputValue === undefined || inputValue === null) {
return true;
}
switch (condition) {
case Condition.EQUAL:
return metricValue === inputValue;
case Condition.GREATER:
return metricValue > inputValue;
case Condition.GREATER_OR_EQUAL:
return metricValue >= inputValue;
case Condition.LESS:
return metricValue < inputValue;
case Condition.LESS_OR_EQUAL:
return metricValue <= inputValue;
default:
throw new Error(`Unknown condition: ${condition}`);
}
}
private _getChartwerkIconPosition(position: IconPosition): string {
// TODO: use chartwerk types
switch (position) {
case IconPosition.MIDDLE:
return 'middle';
case IconPosition.UPPER_LEFT:
return 'left';
case IconPosition.UPPER_RIGHT:
return 'right';
default:
throw new Error(`Unknown Icon Position ${position}`);
}
}
getChartwerkOptions(): any {
console.log('opt', this.maxValue, this.minValue);
return {
maxValue: this.maxValue,
minValue: this.minValue,
valueFormatter: (val: any) => val.toFixed(2),
defaultColor: 'green',
valueFormatter: (val: number) => this._valueFormatter(val),
defaultColor: this.grafanaOptions.gauge.thresholds.defaultColor,
valueArcBackgroundColor: this.grafanaOptions.gauge.thresholds.arcBackground,
reversed: this.grafanaOptions.gauge.reversed,
stops: [
{
color: 'green',
value: 100,
},
{
color: 'orange',
value: 140,
},
],
// @ts-ignore
icons: [{ src: 'https://cityhost.ua/upload_img/blog5ef308ea5529c_trash2-01.jpg', position: 'middle', size: 30 }],
stops: this.thresholds,
valueFontSize: this.grafanaOptions.gauge.valueSize,
icons: this.icons,
};
}
getLastValueFromMetrics(metricName: string | undefined, optionName: string): number | null {
// optionName -> helper in Error, mb use option path instead
const filteredSeries = filterMetricListByAlias(this.grafanaSeriesList, metricName, optionName);
const serie = filteredSeries[0];
// Last value for now
return getAggregatedValueFromSerie(serie, Aggregation.LAST);
}
}

2
src/models/series.ts

@ -29,7 +29,7 @@ export class Series {
return {
target: serie.alias,
color: serie.color,
datapoints: _.map(serie.datapoints, (row) => _.reverse(row)),
datapoints: _.map(serie.datapoints, (row) => _.reverse(_.clone(row))),
alias: serie.label,
};
});

235
src/module.ts

@ -9,124 +9,131 @@ import { UseMetricEditor } from './components/editors/UseMetricEditor';
import { PanelPlugin } from '@grafana/data';
export const plugin = new PanelPlugin<PanelOptions>(Panel).setPanelOptions((builder) => {
return builder
.addRadio({
path: 'visualizationType',
name: 'Pod',
category: ['Visualization'],
defaultValue: Pod.GAUGE,
settings: {
options: [
{
label: 'Gauge',
value: Pod.GAUGE,
},
{
label: 'Line',
value: Pod.LINE,
},
{
label: 'Bar',
value: Pod.BAR,
},
],
},
})
.addCustomEditor({
id: 'notSupportedText',
name: 'This visualization is not supported',
category: ['Visualization'],
path: '',
showIf: (config) => config.visualizationType !== Pod.GAUGE,
editor: NotSupportedText as any,
})
return (
builder
.addRadio({
path: 'visualizationType',
name: 'Pod',
category: ['Visualization'],
defaultValue: Pod.GAUGE,
settings: {
options: [
{
label: 'Gauge',
value: Pod.GAUGE,
},
{
label: 'Line',
value: Pod.LINE,
},
{
label: 'Bar',
value: Pod.BAR,
},
],
},
})
.addCustomEditor({
id: 'notSupportedText',
name: 'This visualization is not supported',
category: ['Visualization'],
path: '',
showIf: (config) => config.visualizationType !== Pod.GAUGE,
editor: NotSupportedText as any,
})
.addFieldNamePicker({
name: 'Value',
path: 'gauge.value.metricName',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
// TODO: defaults?
.addCustomEditor({
id: 'min',
name: 'Min',
path: 'gauge.min',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: UseMetricEditor as any,
}).
addCustomEditor({
id: 'max',
name: 'Max',
path: 'gauge.max',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: UseMetricEditor as any,
})
.addFieldNamePicker({
name: 'Value',
path: 'gauge.value.metricName',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
// TODO: defaults?
.addCustomEditor({
id: 'min',
name: 'Min',
path: 'gauge.min',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: UseMetricEditor as any,
})
.addCustomEditor({
id: 'max',
name: 'Max',
path: 'gauge.max',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: UseMetricEditor as any,
})
// note: `gauge.unit` will contain unit name, not it's string representation
// to format value with unit, use `getValueFormat` function from `@grafana/data`
.addUnitPicker({
path: 'gauge.unit',
name: 'Unit',
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addNumberInput({
path: 'gauge.decimals',
name: 'Decimals',
settings: {
placeholder: 'auto',
min: 0,
max: 5,
},
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addSliderInput({
path: 'gauge.size',
defaultValue: 20,
name: 'Size (px)',
settings: {
min: 1,
max: 50,
},
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
// note: `gauge.unit` will contain unit name, not it's string representation
// to format value with unit, use `getValueFormat` function from `@grafana/data`
.addUnitPicker({
path: 'gauge.unit',
name: 'Unit',
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addNumberInput({
path: 'gauge.decimals',
name: 'Decimals',
settings: {
placeholder: 'auto',
min: 0,
max: 5,
},
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addSliderInput({
path: 'gauge.valueSize',
defaultValue: 20,
name: 'Size (px)',
settings: {
min: 1,
max: 50,
},
category: ['Value Format'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addBooleanSwitch({
path: 'gauge.reversed',
name: 'Reversed',
defaultValue: false,
category: ['Direction'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addBooleanSwitch({
path: 'gauge.reversed',
name: 'Reversed',
defaultValue: false,
category: ['Direction'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addCustomEditor({
id: 'icons',
path: 'gauge.icons',
name: 'Icons',
category: ['Icons'],
defaultValue: [],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: IconsEditor as any,
})
.addCustomEditor({
id: 'icons',
path: 'gauge.icons',
name: 'Icons',
category: ['Icons'],
defaultValue: [],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: IconsEditor as any,
})
.addCustomEditor({
id: 'thresholds',
path: 'gauge.thresholds',
name: 'Thresholds',
category: ['Thresholds'],
defaultValue: {
defaultColor: '#37872d',
arcBackground: 'rgba(38, 38, 38, 0.1)',
thresholds: [],
},
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: ThresholdsEditor as any,
});
.addCustomEditor({
id: 'thresholds',
path: 'gauge.thresholds',
name: 'Thresholds',
category: ['Thresholds'],
defaultValue: {
defaultColor: '#37872d',
arcBackground: 'rgba(38, 38, 38, 0.1)',
thresholds: [],
},
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: ThresholdsEditor as any,
})
.addTextInput({
path: 'gauge.link',
name: '',
category: ['Link'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
);
});

34
src/types.ts

@ -4,7 +4,17 @@ export interface PanelOptions {
min: ExtremumOptions;
max: ExtremumOptions;
value: ExtremumOptions;
valueSize: number;
reversed: boolean;
thresholds: {
arcBackground: string;
defaultColor: string;
thresholds: Threshold[];
};
icons: Icon[];
decimals?: number;
unit?: string;
link?: string;
};
}
@ -35,3 +45,27 @@ export enum Aggregation {
MAX = 'max',
LAST = 'last',
}
export type Threshold = {
useMetric: boolean;
value: number;
metricName: string;
color: string;
};
export type Icon = {
conditions: Condition[];
metrics: string[];
values: number[];
position: IconPosition;
size: number;
url: string;
};
export enum Condition {
EQUAL = '=',
GREATER = '>',
LESS = '<',
GREATER_OR_EQUAL = '>=',
LESS_OR_EQUAL = '<=',
}

10
src/utils.ts

@ -13,8 +13,13 @@ export function filterMetricListByAlias(list: any[], alias: string | undefined,
return filteredSeries;
}
export function getAggregatedValueFromSerie(serie: any, aggregation = Aggregation.LAST): number | null {
export function getAggregatedValueFromSerie(
serie: any,
aggregation = Aggregation.LAST,
valueIdx: 0 | 1 = 0
): number | null {
// series types { datapoints: [number, number][]}
// valueIdx === 0 for Grafana series, valueIdx === 1 for Chartwerk series
if (serie === undefined) {
return null;
}
@ -24,9 +29,8 @@ export function getAggregatedValueFromSerie(serie: any, aggregation = Aggregatio
switch (aggregation) {
case Aggregation.LAST:
const lastRow = _.last(serie.datapoints as Array<[number, number]>);
// [0] because it is Grafan series. So 0 idx for values, 1 idx for timestamps
// @ts-ignore
return !_.isEmpty(lastRow) ? lastRow[0] : null;
return !_.isEmpty(lastRow) ? lastRow[valueIdx] : null;
default:
throw new Error(`Unknown aggregation type: ${aggregation}`);
}

Loading…
Cancel
Save