diff --git a/package.json b/package.json index 66a656e..ccccb3d 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "author": "CorpGlory", "license": "GPL V3", "devDependencies": { - "@chartwerk/gauge-pod": "@chartwerk/gauge-pod", + "@chartwerk/gauge-pod": "0.4.1", "@grafana/data": "latest", "@grafana/toolkit": "latest", "@grafana/ui": "latest", + "@types/grafana": "github:CorpGlory/types-grafana#8c55ade5212f089748f6955f73e8f753fff9f278", "emotion": "10.0.27", + "lodash": "^4.17.21", "prettier": "^2.6.2" }, "engines": { diff --git a/src/SimplePanel.tsx b/src/SimplePanel.tsx deleted file mode 100644 index 148494f..0000000 --- a/src/SimplePanel.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { PanelProps } from '@grafana/data'; -import { PanelOptions } from 'types'; -import { css, cx } from 'emotion'; -import { stylesFactory, useTheme } from '@grafana/ui'; - -interface Props extends PanelProps {} - -export const SimplePanel: React.FC = ({ options, data, width, height }) => { - const theme = useTheme(); - const styles = getStyles(); - return ( -
- - - - - -
- ); -}; - -const getStyles = stylesFactory(() => { - return { - wrapper: css` - position: relative; - `, - svg: css` - position: absolute; - top: 0; - left: 0; - `, - textBox: css` - position: absolute; - bottom: 0; - left: 0; - padding: 10px; - `, - }; -}); diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index 95e4eb1..ca1b6e5 100644 --- a/src/components/Panel.tsx +++ b/src/components/Panel.tsx @@ -1,3 +1,5 @@ +import { DataProcessor } from '../grafana/data_processor'; + import { PanelOptions } from '../types'; import { ChartwerkGaugePod } from '@chartwerk/gauge-pod'; @@ -6,10 +8,20 @@ import { PanelProps } from '@grafana/data'; import React, { useRef } from 'react'; import { css } from 'emotion'; +import * as _ from 'lodash'; interface Props extends PanelProps {} export function Panel({ options, data, width, height, timeZone, timeRange, onChangeTimeRange }: Props) { + console.log('options', options); + console.log('data', data); + const processor = new DataProcessor({}); + const seriesList = processor.getSeriesList({ + dataList: data.series, + range: timeRange, + }); + const chwSeriesList = updateSeriesListWithChartwerkParams(seriesList); + console.log('seriesList', chwSeriesList); let chartContainer = useRef(null); // TODO: use models to parse options and series @@ -18,25 +30,24 @@ export function Panel({ options, data, width, height, timeZone, timeRange, onCha // TODO: pass datapoints // TODO: pass options // TODO: switch / case pod type - const pod = new ChartwerkGaugePod( - (chartContainer as any).current, - [ + const pod = new ChartwerkGaugePod((chartContainer as any).current, chwSeriesList, { + maxValue: options.gauge.max, + minValue: options.gauge.min, + valueFormatter: (val) => val.toFixed(2), + defaultColor: 'green', + stops: [ + { + color: 'green', + value: 100, + }, { - target: 'test', - datapoints: [ - [5, 5], - [0, 10], - [30, 15], - [50, 20], - [17, 25], - ], + color: 'orange', + value: 140, }, ], - { - maxValue: options.gauge.max, - minValue: options.gauge.min, - } - ); + // @ts-ignore + icons: [{ src: 'https://cityhost.ua/upload_img/blog5ef308ea5529c_trash2-01.jpg', position: 'middle', size: 30 }], + }); pod.render(); }); return ( @@ -49,3 +60,14 @@ export function Panel({ options, data, width, height, timeZone, timeRange, onCha > ); } + +function updateSeriesListWithChartwerkParams(series: any[]): any[] { + return _.map(series, (serie: any, idx: number) => { + return { + target: serie.alias, + color: serie.color, + datapoints: _.map(serie.datapoints, (row) => _.reverse(row)), + alias: serie.label, + }; + }); +} diff --git a/src/grafana/data_processor.ts b/src/grafana/data_processor.ts new file mode 100644 index 0000000..530bb6b --- /dev/null +++ b/src/grafana/data_processor.ts @@ -0,0 +1,194 @@ +import { applyNullInsertThreshold } from './null_insert'; + +import { find } from 'lodash'; + +import { + DataFrame, + dateTime, + Field, + FieldType, + getColorForTheme, + getFieldDisplayName, + getTimeField, + TimeRange, +} from '@grafana/data'; +import { colors } from '@grafana/ui'; +import config from 'grafana/app/core/config'; +import TimeSeries from 'grafana/app/core/time_series2'; + +type Options = { + dataList: DataFrame[]; + range?: TimeRange; +}; + +export class DataProcessor { + constructor(private panel: any) {} + + getSeriesList(options: Options): TimeSeries[] { + const list: TimeSeries[] = []; + const { dataList, range } = options; + + if (!dataList || !dataList.length) { + return list; + } + + for (let i = 0; i < dataList.length; i++) { + let series = dataList[i]; + let { timeField } = getTimeField(series); + + if (!timeField) { + continue; + } + + series = applyNullInsertThreshold(series, timeField.name); + timeField = getTimeField(series).timeField!; // use updated length + + for (let j = 0; j < series.fields.length; j++) { + const field = series.fields[j]; + + if (field.type !== FieldType.number) { + continue; + } + const name = getFieldDisplayName(field, series, dataList); + const datapoints = []; + + for (let r = 0; r < series.length; r++) { + datapoints.push([field.values.get(r), dateTime(timeField.values.get(r)).valueOf()]); + } + + list.push(this.toTimeSeries(field, name, i, j, datapoints, list.length, range)); + } + } + + // Merge all the rows if we want to show a histogram + if (this.panel?.xaxis?.mode === 'histogram' && !this.panel?.stack && list.length > 1) { + const first = list[0]; + // @ts-ignore + first.alias = first.aliasEscaped = 'Count'; + + for (let i = 1; i < list.length; i++) { + first.datapoints = first.datapoints.concat(list[i].datapoints); + } + + return [first]; + } + + return list; + } + + private toTimeSeries( + field: Field, + alias: string, + dataFrameIndex: number, + fieldIndex: number, + datapoints: any[][], + index: number, + range?: TimeRange + ) { + const colorIndex = index % colors.length; + const color = colors[colorIndex]; + + const series = new TimeSeries({ + datapoints: datapoints || [], + alias: alias, + color: getColorForTheme(color, config.theme), + unit: field.config ? field.config.unit : undefined, + dataFrameIndex, + fieldIndex, + }); + + if (datapoints && datapoints.length > 0 && range) { + const last = datapoints[datapoints.length - 1][1]; + const from = range.from; + + if (last - from.valueOf() < -10000) { + // If the data is in reverse order + const first = datapoints[0][1]; + if (first - from.valueOf() < -10000) { + series.isOutsideRange = true; + } + } + } + return series; + } + + setPanelDefaultsForNewXAxisMode() { + switch (this.panel.xaxis.mode) { + case 'time': { + this.panel.bars = false; + this.panel.lines = true; + this.panel.points = false; + this.panel.legend.show = true; + this.panel.tooltip.shared = true; + this.panel.xaxis.values = []; + break; + } + case 'series': { + this.panel.bars = true; + this.panel.lines = false; + this.panel.points = false; + this.panel.stack = false; + this.panel.legend.show = false; + this.panel.tooltip.shared = false; + this.panel.xaxis.values = ['total']; + break; + } + case 'histogram': { + this.panel.bars = true; + this.panel.lines = false; + this.panel.points = false; + this.panel.stack = false; + this.panel.legend.show = false; + this.panel.tooltip.shared = false; + break; + } + } + } + + validateXAxisSeriesValue() { + switch (this.panel.xaxis.mode) { + case 'series': { + if (this.panel.xaxis.values.length === 0) { + this.panel.xaxis.values = ['total']; + return; + } + + const validOptions = this.getXAxisValueOptions({}); + const found: any = find(validOptions, { value: this.panel.xaxis.values[0] }); + if (!found) { + this.panel.xaxis.values = ['total']; + } + return; + } + } + } + + getXAxisValueOptions(options: any) { + switch (this.panel.xaxis.mode) { + case 'series': { + return [ + { text: 'Avg', value: 'avg' }, + { text: 'Min', value: 'min' }, + { text: 'Max', value: 'max' }, + { text: 'Total', value: 'total' }, + { text: 'Count', value: 'count' }, + ]; + } + } + + return []; + } + + pluckDeep(obj: any, property: string) { + const propertyParts = property.split('.'); + let value = obj; + for (let i = 0; i < propertyParts.length; ++i) { + if (value[propertyParts[i]]) { + value = value[propertyParts[i]]; + } else { + return undefined; + } + } + return value; + } +} diff --git a/src/grafana/null_insert.ts b/src/grafana/null_insert.ts new file mode 100644 index 0000000..c1a9089 --- /dev/null +++ b/src/grafana/null_insert.ts @@ -0,0 +1,129 @@ +// file is coppied from: grafana-ui\src\components\GraphNG\nullInsertThreshold.ts + +import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; + +type InsertMode = (prev: number, next: number, threshold: number) => number; + +const INSERT_MODES = { + threshold: (prev: number, next: number, threshold: number) => prev + threshold, + midpoint: (prev: number, next: number, threshold: number) => (prev + next) / 2, + // previous time + 1ms to prevent StateTimeline from forward-interpolating prior state + plusone: (prev: number, next: number, threshold: number) => prev + 1, +}; + +export function applyNullInsertThreshold( + frame: DataFrame, + refFieldName?: string | null, + refFieldPseudoMax: number | null = null, + insertMode: InsertMode = INSERT_MODES.threshold +): DataFrame { + if (frame.length === 0) { + return frame; + } + + const refField = frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); + + if (refField == null) { + return frame; + } + + const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null); + + const uniqueThresholds = new Set(thresholds); + + uniqueThresholds.delete(null as any); + + if (uniqueThresholds.size === 0) { + return frame; + } + + if (uniqueThresholds.size === 1) { + const threshold = uniqueThresholds.values().next().value; + + if (threshold <= 0) { + return frame; + } + + const refValues = refField.values.toArray(); + + const frameValues = frame.fields.map((field) => field.values.toArray()); + + const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, refFieldPseudoMax, insertMode); + + if (filledFieldValues === frameValues) { + return frame; + } + + return { + ...frame, + length: filledFieldValues[0].length, + fields: frame.fields.map((field, i) => ({ + ...field, + values: new ArrayVector(filledFieldValues[i]), + })), + }; + } + + // TODO: unique threshold-per-field (via overrides) is unimplemented + // should be done by processing each (refField + thresholdA-field1 + thresholdA-field2...) + // as a separate nullInsertThreshold() dataset, then re-join into single dataset via join() + return frame; +} + +function nullInsertThreshold( + refValues: number[], + frameValues: any[][], + threshold: number, + // will insert a trailing null when refFieldPseudoMax > last datapoint + threshold + refFieldPseudoMax: number | null = null, + getInsertValue: InsertMode +) { + const len = refValues.length; + let prevValue: number = refValues[0]; + const refValuesNew: number[] = [prevValue]; + + for (let i = 1; i < len; i++) { + const curValue = refValues[i]; + + if (curValue - prevValue > threshold) { + refValuesNew.push(getInsertValue(prevValue, curValue, threshold)); + } + + refValuesNew.push(curValue); + + prevValue = curValue; + } + + if (refFieldPseudoMax != null && prevValue + threshold <= refFieldPseudoMax) { + refValuesNew.push(getInsertValue(prevValue, refFieldPseudoMax, threshold)); + } + + const filledLen = refValuesNew.length; + + if (filledLen === len) { + return frameValues; + } + + const filledFieldValues: any[][] = []; + + for (let fieldValues of frameValues) { + let filledValues; + + if (fieldValues !== refValues) { + filledValues = Array(filledLen); + + for (let i = 0, j = 0; i < filledLen; i++) { + filledValues[i] = refValues[j] === refValuesNew[i] ? fieldValues[j++] : null; + } + } else { + filledValues = refValuesNew; + } + + filledFieldValues.push(filledValues); + } + + return filledFieldValues; +} diff --git a/src/img/logo.svg b/src/img/logo.svg deleted file mode 100644 index 3d284de..0000000 --- a/src/img/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 36af8ff..acc72c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,10 +906,10 @@ version "0.1.0" resolved "https://codeload.github.com/chartwerk/core/tar.gz/a30ca83842247c79969deaaacfc7fb444a60cefb" -"@chartwerk/gauge-pod@@chartwerk/gauge-pod": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@chartwerk/gauge-pod/-/gauge-pod-0.4.0.tgz#affe5354ce4a5d069a1ddc97d9bc71c8fd968d12" - integrity sha512-avOwJEeqWYshCN7XCPtzFrTYVFNpWNdFfDjrt+4i3zKHuCSiV0rTvHPAoKPvJc2ue50CTOdcT+3mOXJp6NtPZQ== +"@chartwerk/gauge-pod@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@chartwerk/gauge-pod/-/gauge-pod-0.4.1.tgz#ac346d777f72ec855e51f5f7c8c01e12a1e1cb5c" + integrity sha512-Ik6Dr4AJP/L+7YjZVJ9W19ujXXB5/b5A3Qxboi491hrXYlqMrfAx/LoyfDAtSEmGNhK5qpT8XLluzuHVcgTY4g== dependencies: "@chartwerk/core" "github:chartwerk/core#a30ca83842247c79969deaaacfc7fb444a60cefb" @@ -2276,6 +2276,10 @@ dependencies: "@types/node" "*" +"@types/grafana@github:CorpGlory/types-grafana#8c55ade5212f089748f6955f73e8f753fff9f278": + version "4.6.3" + resolved "https://codeload.github.com/CorpGlory/types-grafana/tar.gz/8c55ade5212f089748f6955f73e8f753fff9f278" + "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"