diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index 3cba27f..50213a2 100644 --- a/src/components/Panel.tsx +++ b/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) => { + event.preventDefault(); + if (!isLinkActive) { + return; + } + window.open(options.gauge.link, '_self'); + }; + return (
); } diff --git a/src/components/editors/IconsEditor.tsx b/src/components/editors/IconsEditor.tsx index 9aee9cd..b989d7f 100644 --- a/src/components/editors/IconsEditor.tsx +++ b/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> = [ { label: 'Upper-Left', @@ -73,7 +56,7 @@ const conditionOptions: Array> = [ }, ]; -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) { +export function IconsEditor({ onChange, value, context }: StandardEditorProps) { const icons = value; const addIcon = () => { @@ -113,14 +96,14 @@ export function IconsEditor({ onChange, value, context }: StandardEditorProps { + 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 { return (
- removeIcon(iconIdx)} - tooltip="Delete Icon" - > + removeIcon(iconIdx)} tooltip="Delete Icon">
{ return ( -
- +
+ { margin-bottom: ${theme.spacing.sm}; `, }; - }); diff --git a/src/components/editors/NotSupportedText.tsx b/src/components/editors/NotSupportedText.tsx index 22c4432..3b50408 100644 --- a/src/components/editors/NotSupportedText.tsx +++ b/src/components/editors/NotSupportedText.tsx @@ -1,5 +1,5 @@ import React from 'react'; export function NotSupportedText() { - return
To be supported soon...
+ return
To be supported soon...
; } diff --git a/src/components/editors/ThresholdsEditor.tsx b/src/components/editors/ThresholdsEditor.tsx index f31df00..354b4d3 100644 --- a/src/components/editors/ThresholdsEditor.tsx +++ b/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 (
- + - + 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; `, }; }); diff --git a/src/components/editors/UseMetricEditor.tsx b/src/components/editors/UseMetricEditor.tsx index 37f7de0..4018e26 100644 --- a/src/components/editors/UseMetricEditor.tsx +++ b/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 ( @@ -53,10 +47,11 @@ export function UseMetricEditor({ onChange, value, context }: StandardEditorProp onFieldChange('value', (evt.target as any).value)} /> )} - ) + ); } diff --git a/src/models/options.ts b/src/models/options.ts index 4b071b9..f150d29 100644 --- a/src/models/options.ts +++ b/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); + } } diff --git a/src/models/series.ts b/src/models/series.ts index b1956a9..a2b7b29 100644 --- a/src/models/series.ts +++ b/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, }; }); diff --git a/src/module.ts b/src/module.ts index af79332..4c0925e 100644 --- a/src/module.ts +++ b/src/module.ts @@ -9,124 +9,131 @@ import { UseMetricEditor } from './components/editors/UseMetricEditor'; import { PanelPlugin } from '@grafana/data'; - export const plugin = new PanelPlugin(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, + }) + ); }); diff --git a/src/types.ts b/src/types.ts index 923853c..cf3f960 100644 --- a/src/types.ts +++ b/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 = '<=', +} diff --git a/src/utils.ts b/src/utils.ts index dff1836..007f8d2 100644 --- a/src/utils.ts +++ b/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}`); }