// Libraries import _ from 'lodash'; import { auto } from 'angular'; // Components import './color_legend'; import { optionsEditorCtrl } from './options_editor'; import { tooltipEditorCtrl } from './tooltip_editor'; import { migratePanelConfig } from './panel_config_migration'; // Utils import kbn from 'app/core/utils/kbn'; import {loadPluginCss} from 'app/plugins/sdk'; // Types import { MetricsPanelCtrl } from 'app/plugins/sdk'; import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; import { CoreEvents, PanelEvents } from './libs/grafana/events/index'; import {Bucket, BucketMatrix, BucketMatrixPager } from './statusmap_data'; import rendering from './rendering'; import { Polygrafill } from './libs/polygrafill/index'; import {ColorModeDiscrete} from "./color_mode_discrete"; const VALUE_INDEX = 0, TIME_INDEX = 1; const colorSchemes = [ // Diverging { name: 'Spectral', value: 'interpolateSpectral', invert: 'always' }, { name: 'RdYlGn', value: 'interpolateRdYlGn', invert: 'always' }, { name: 'GnYlRd', value: 'interpolateGnYlRd', invert: 'always' }, // Sequential (Single Hue) { name: 'Blues', value: 'interpolateBlues', invert: 'dark' }, { name: 'Greens', value: 'interpolateGreens', invert: 'dark' }, { name: 'Greys', value: 'interpolateGreys', invert: 'dark' }, { name: 'Oranges', value: 'interpolateOranges', invert: 'dark' }, { name: 'Purples', value: 'interpolatePurples', invert: 'dark' }, { name: 'Reds', value: 'interpolateReds', invert: 'dark' }, // Sequential (Multi-Hue) { name: 'BuGn', value: 'interpolateBuGn', invert: 'dark' }, { name: 'BuPu', value: 'interpolateBuPu', invert: 'dark' }, { name: 'GnBu', value: 'interpolateGnBu', invert: 'dark' }, { name: 'OrRd', value: 'interpolateOrRd', invert: 'dark' }, { name: 'PuBuGn', value: 'interpolatePuBuGn', invert: 'dark' }, { name: 'PuBu', value: 'interpolatePuBu', invert: 'dark' }, { name: 'PuRd', value: 'interpolatePuRd', invert: 'dark' }, { name: 'RdPu', value: 'interpolateRdPu', invert: 'dark' }, { name: 'YlGnBu', value: 'interpolateYlGnBu', invert: 'dark' }, { name: 'YlGn', value: 'interpolateYlGn', invert: 'dark' }, { name: 'YlOrBr', value: 'interpolateYlOrBr', invert: 'dark' }, { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' } ]; let colorModes = ['opacity', 'spectrum', 'discrete']; let opacityScales = ['linear', 'sqrt']; loadPluginCss({ dark: 'plugins/flant-statusmap-panel/css/statusmap.dark.css', light: 'plugins/flant-statusmap-panel/css/statusmap.light.css' }); export var renderComplete:any = {name:'statusmap-render-complete'}; // eventFactory('statusmap-render-complete'); class StatusHeatmapCtrl extends MetricsPanelCtrl { static templateUrl = 'module.html'; data: any; bucketMatrix: BucketMatrix; bucketMatrixPager: BucketMatrixPager; graph: any; discreteHelper: ColorModeDiscrete; opacityScales: any = []; colorModes: any = []; colorSchemes: any = []; unitFormats: any; dataWarnings: {[warningId: string]: {title: string, tip: string}} = {}; multipleValues: boolean; noColorDefined: boolean; noDatapoints: boolean; discreteExtraSeries: ColorModeDiscrete; annotations: object[] = []; annotationsPromise: any; // TODO remove this transient variable: use ng-model-options="{ getterSetter: true }" pageSizeViewer: number = 15; panelDefaults: any = { // datasource name, null = default datasource datasource: null, // color mode color: { mode: 'spectrum', cardColor: '#b4ff00', colorScale: 'sqrt', exponent: 0.5, colorScheme: 'interpolateGnYlRd', // discrete mode settings defaultColor: '#757575', thresholds: [] // manual colors }, // buckets settings cards: { cardMinWidth: 5, cardVSpacing: 2, cardHSpacing: 2, cardRound: null }, xAxis: { show: true }, yAxis: { show: true, minWidth: -1, maxWidth: -1 }, tooltip: { show: true, freezeOnClick: true, showItems: false, items: [] // see tooltip_editor.ts }, legend: { show: true }, // how null points should be handled nullPointMode: 'as empty', yAxisSort: 'metrics', highlightCards: true, useMax: true, seriesFilterIndex: -1, // Pagination options usingPagination: false, pageSize: 15 }; /** @ngInject */ constructor($scope: any, $injector: auto.IInjectorService, timeSrv, private annotationsSrv: AnnotationsSrv, $window, datasourceSrv, public variableSrv: any, templateSrv) { super($scope, $injector); if(!Polygrafill.hasAppEventCompatibleEmitter(this.events)){ CoreEvents.fallbackToStringEvents(); PanelEvents.fallbackToStringEvents(); renderComplete = renderComplete.name; } migratePanelConfig(this.panel); _.defaultsDeep(this.panel, this.panelDefaults); this.bucketMatrix = new BucketMatrix(); // Create pager for bucketMatrix and restore page size. this.bucketMatrixPager = new BucketMatrixPager(); this.bucketMatrixPager.setEnable(this.panel.usingPagination); this.bucketMatrixPager.setDefaultPageSize(this.panel.pageSize); this.bucketMatrixPager.setPageSize(this.panel.pageSize); $scope.pager = this.bucketMatrixPager; this.opacityScales = opacityScales; this.colorModes = colorModes; this.colorSchemes = colorSchemes; // default graph width for discrete card width calculation this.graph = { "chartWidth" : -1 }; this.multipleValues = false; this.noColorDefined = false; this.discreteExtraSeries = new ColorModeDiscrete($scope); this.dataWarnings = { noColorDefined: { title: 'Data has value with undefined color', tip: 'Check metric values, color values or define a new color', }, multipleValues: { title: 'Data has multiple values for one target', tip: 'Change targets definitions or set "use max value"', }, noDatapoints: { title: 'No data points', tip: 'No datapoints returned from data query', } }; this.annotations = []; this.annotationsSrv = annotationsSrv; this.timeSrv = timeSrv; this.events.on(PanelEvents.render, this.onRender.bind(this)); this.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this)); this.events.on(PanelEvents.dataError, this.onDataError.bind(this)); this.events.on(PanelEvents.dataSnapshotLoad, this.onDataReceived.bind(this)); this.events.on(PanelEvents.editModeInitialized, this.onInitEditMode.bind(this)); this.events.on(PanelEvents.refresh, this.postRefresh.bind(this)); // custom event from rendering.js this.events.on(renderComplete, this.onRenderComplete.bind(this)); this.onCardColorChange = this.onCardColorChange.bind(this); } onRenderComplete(data: any):void { this.graph.chartWidth = data.chartWidth; this.renderingCompleted(); } changeDefaultPaginationSize(defaultPageSize: number): void { this.bucketMatrixPager.setDefaultPageSize(defaultPageSize); this.bucketMatrixPager.setPageSize(defaultPageSize); this.pageSizeViewer = defaultPageSize; this.render(); this.refresh(); } onChangePageSize(): void { if (this.pageSizeViewer <= 0) { this.pageSizeViewer = this.bucketMatrixPager.defaultPageSize; } this.bucketMatrixPager.setPageSize(this.pageSizeViewer); this.bucketMatrixPager.setCurrent(0); this.render(); this.refresh(); } onPrevPage(): void { this.bucketMatrixPager.switchToPrev(); this.render(); } onNextPage(): void { this.bucketMatrixPager.switchToNext(); this.render(); } // getChartWidth returns an approximation of chart canvas width or // a saved value calculated during a render. getChartWidth():number { if (this.graph.chartWidth > 0) { return this.graph.chartWidth; } const wndWidth = $(window).width(); // gripPos.w is a width in grid's measurements. Grid size in Grafana is 24. const panelWidthFactor = this.panel.gridPos.w / 24; const panelWidth = Math.ceil(wndWidth * panelWidthFactor); // approximate width of the chart draw canvas: // - y axis ticks are not rendered yet on first data receive, // so choose 200 as a decent value for y legend width // - chartWidth can not be lower than the half of the panel width. const chartWidth = _.max([ panelWidth - 200, panelWidth/2 ]); return chartWidth!; } // Quick workaround for 6.7 and 7.0+. There is no call to // calculateInterval in updateTimeRange in those versions. // TODO ts type has no argument for this method. updateTimeRange(datasource?: any) { let ret = super.updateTimeRange(datasource); this.calculateInterval(); return ret; } // calculateInterval is called on 'refresh' to calculate an interval // for datasource. // It is override of calculateInterval from MetricsPanelCtrl. calculateInterval() { let chartWidth = this.getChartWidth(); let minCardWidth = this.panel.cards.cardMinWidth; let minSpacing = this.panel.cards.cardHSpacing; let maxCardsCount = Math.ceil((chartWidth-minCardWidth) / (minCardWidth + minSpacing)); let intervalMs; let rangeMs = this.range.to.valueOf() - this.range.from.valueOf(); // this is minimal interval! kbn.round_interval will lower it. intervalMs = this.discreteExtraSeries.roundIntervalCeil(rangeMs / maxCardsCount); // Calculate low limit of interval let lowLimitMs = 1; // 1 millisecond default low limit let intervalOverride = this.panel.interval; // if no panel interval check datasource if (intervalOverride) { intervalOverride = this.templateSrv.replace(intervalOverride, this.panel.scopedVars); } else if (this.datasource && this.datasource.interval) { intervalOverride = this.datasource.interval; } if (intervalOverride) { if (intervalOverride[0] === '>') { intervalOverride = intervalOverride.slice(1); } lowLimitMs = kbn.interval_to_ms(intervalOverride); } if (lowLimitMs > intervalMs) { intervalMs = lowLimitMs; } let interval = kbn.secondsToHms(intervalMs / 1000); this.intervalMs = intervalMs; this.interval = interval; // Get final buckets count after interval is adjusted // TODO is it needed? //this.xBucketsCount = Math.floor(rangeMs / intervalMs); } issueQueries(datasource: any) { this.annotationsPromise = this.annotationsSrv.getAnnotations({ dashboard: this.dashboard, panel: this.panel, range: this.range, }); /* Wait for annotationSrv requests to get datasources to * resolve before issuing queries. This allows the annotations * service to fire annotations queries before graph queries * (but not wait for completion). This resolves * issue 11806. */ // 5.x before 5.4 doesn't have datasourcePromises. if ("undefined" !== typeof(this.annotationsSrv.datasourcePromises)) { return this.annotationsSrv.datasourcePromises.then(r => { return this.issueQueriesWithInterval(datasource, this.interval); }); } else { return this.issueQueriesWithInterval(datasource, this.interval); } } // Grafana 6.2 (and older) is using this.interval for queries, // but Grafana 6.3+ is calculating interval again in queryRunner, // so we need to save-restore this.panel.interval. issueQueriesWithInterval(datasource: any, interval: any) { var origInterval = this.panel.interval; this.panel.interval = interval; var res = super.issueQueries(datasource); this.panel.interval = origInterval; return res; } onDataReceived(dataList: any) { this.data = dataList; // Quick workaround for 7.0+. There is no call to // calculateInterval when enter Edit mode. if (!this.intervalMs) { this.calculateInterval(); } let newBucketMatrix = this.convertDataToBuckets(dataList, this.range.from.valueOf(), this.range.to.valueOf(), this.intervalMs, true); this.bucketMatrix = newBucketMatrix; this.bucketMatrixPager.bucketMatrix = newBucketMatrix; if (newBucketMatrix.targets.length !== this.bucketMatrix.targets.length) { this.bucketMatrixPager.setCurrent(0); } this.noDatapoints = this.bucketMatrix.noDatapoints; if (this.annotationsPromise) { this.annotationsPromise.then( (result: { alertState: any; annotations: any }) => { this.loading = false; //this.alertState = result.alertState; if (result.annotations && result.annotations.length > 0) { this.annotations = result.annotations; } else { this.annotations = []; } this.render(); }, () => { this.loading = false; this.annotations = []; this.render(); } ); } else { this.loading = false; this.annotations = []; this.render(); } } onInitEditMode() { this.addEditorTab('Options', optionsEditorCtrl, 2); this.addEditorTab('Tooltip', tooltipEditorCtrl, 3); this.unitFormats = kbn.getUnitFormats(); } // onRender will be called before StatusmapRenderer.onRender. // Decide if warning should be displayed over cards. onRender() { if (!this.range || !this.data) { this.noDatapoints = true; return; } this.multipleValues = false; if (!this.panel.useMax) { if (this.bucketMatrix) { this.multipleValues = this.bucketMatrix.multipleValues; } } this.noColorDefined = false; if (this.panel.color.mode === 'discrete') { if (this.panel.seriesFilterIndex == -1) { this.discreteExtraSeries.updateCardsValuesHasColorInfo(); } else { this.discreteExtraSeries.updateCardsValuesHasColorInfoSingle(); } if (this.bucketMatrix) { this.noColorDefined = this.bucketMatrix.noColorDefined; } } this.noDatapoints = false; if (this.bucketMatrix) { this.noDatapoints = this.bucketMatrix.noDatapoints; } } onCardColorChange(newColor) { this.panel.color.cardColor = newColor; this.render(); } onDataError() { this.data = []; this.annotations = []; this.render(); } postRefresh() { this.noColorDefined = false; } link(scope, elem, attrs, ctrl) { rendering(scope, elem, attrs, ctrl); } // Compatible with Grafana 7.0 overrides feature. retrieveTimeVar() { var time = this.timeSrv.timeRangeForUrl(); return 'from=' + time.from + '&to=' + time.to; } // convertToBuckets groups values in data into buckets by target and timestamp. // // data is a result from datasource. It is an array of timeseries and tables: /* [ // timeseries { target: "query alias", refId: "A", datapoints: [ [0, 1582681239911], [3, 158....], ... ], tags?:{key: value,...} }, // table { name: "table name", refId: "B", columns: [ {text: "id"}, {text: "info"}, ... ], rows: [ [1, "123"], [2, "44411"], ... ] }, ... ] to and from — a time range of the panel. intervalMs — a calculated interval. It is used to split a time range. */ convertDataToBuckets(data:any, from:number, to:number, intervalMs: number, mostRecentBucket: boolean):BucketMatrix { let bucketMatrix = new BucketMatrix(); bucketMatrix.rangeMs = to - from; bucketMatrix.intervalMs = intervalMs; if (!data || data.length == 0) { // Mimic heatmap and graph 'no data' labels. bucketMatrix.targets = [ "1.0", "0.0", "-1.0" ]; bucketMatrix.buckets["1.0"] = []; bucketMatrix.buckets["0.0"] = []; bucketMatrix.buckets["-1.0"] = []; bucketMatrix.xBucketSize = 42; bucketMatrix.noDatapoints = true; return bucketMatrix; } let targetIndex: {[target: string]: number[]} = {}; // Group indicies of elements in data by target (y label). // lodash version: //_.map(data, (d, i) => { // targetIndex[d.target] = _.concat(_.toArray(targetIndex[d.target]), i); //}); data.map((queryResult: any, i: number) => { let yLabel = queryResult.target; if (!targetIndex.hasOwnProperty(yLabel)) { targetIndex[yLabel] = []; } targetIndex[yLabel].push(i); }); let targetKeys = _.keys(targetIndex); //console.log ("targetIndex: ", targetIndex, "targetKeys: ", targetKeys); let targetTimestampRanges: {[target: string]: {[timestamp: number]: number[]}} = {}; // Collect all timestamps for each target. // Make map timestamp => [from, to]. from == previous ts, to == ts from datapoint. targetKeys.map((target) => { let targetTimestamps: any[] = []; for (let si = 0; si < targetIndex[target].length; si++) { let s = data[targetIndex[target][si]]; _.map(s.datapoints, (datapoint, idx) => { targetTimestamps.push(datapoint[TIME_INDEX]-from); }) } //console.log("timestamps['"+target+"'] = ", targetTimestamps); targetTimestamps = _.uniq(targetTimestamps); //console.log("uniq timestamps['"+target+"'] = ", targetTimestamps); targetTimestampRanges[target] = []; for (let i = targetTimestamps.length-1 ; i>=0; i-- ) { let tsTo = targetTimestamps[i]; let tsFrom = 0; if (tsTo < 0) { tsFrom = tsTo - intervalMs; } else { if (i-1 >= 0) { // Set from to previous timestamp + 1ms; tsFrom = targetTimestamps[i-1]+1; // tfTo - tfFrom should not be more than intervalMs let minFrom = tsTo - intervalMs; if (tsFrom < minFrom) { tsFrom = minFrom; } } } targetTimestampRanges[target][tsTo] = [tsFrom, tsTo]; } }); // console.log ("targetTimestampRanges: ", targetTimestampRanges); // Create empty buckets using intervalMs to calculate ranges. // If mostRecentBucket is set, create a bucket with a range "to":"to" // to store most recent values. targetKeys.map((target) => { let targetEmptyBuckets: any[] = []; let lastTs = to-from; if (mostRecentBucket) { let topBucket = new Bucket(); topBucket.yLabel = target; topBucket.relTo = lastTs; topBucket.relFrom = lastTs; topBucket.values = []; topBucket.mostRecent = true; if (targetTimestampRanges[target].hasOwnProperty(lastTs)) { topBucket.relFrom = targetTimestampRanges[target][lastTs][0]; lastTs = topBucket.relFrom; } topBucket.to = topBucket.relTo+from; topBucket.from = topBucket.relFrom+from; targetEmptyBuckets.push(topBucket); } let idx = 0; let bucketFrom: number = 0; while (bucketFrom >= 0) { let b = new Bucket(); b.yLabel = target; b.relTo = lastTs - idx*intervalMs; b.relFrom = lastTs - ((idx+1) * intervalMs); b.to = b.relTo+from; b.from = b.relFrom+from; b.values = []; bucketFrom = b.relFrom; targetEmptyBuckets.push(b); idx++; } targetEmptyBuckets.map((bucket, i) => { bucket.xid = i; }); bucketMatrix.buckets[target] = targetEmptyBuckets; }); //console.log ("bucketMatrix: ", bucketMatrix); // Put values into buckets. bucketMatrix.minValue = Number.MAX_VALUE; bucketMatrix.maxValue = Number.MIN_SAFE_INTEGER; targetKeys.map((target) => { targetIndex[target].map((dataIndex) => { let s = data[dataIndex]; s.datapoints.map((dp: any) => { for (let i = 0; i < bucketMatrix.buckets[target].length; i++) { if (bucketMatrix.buckets[target][i].belong(dp[TIME_INDEX])) { bucketMatrix.buckets[target][i].put(dp[VALUE_INDEX]); } } }); }); bucketMatrix.buckets[target].map((bucket) => { bucket.minValue = _.min(bucket.values); bucket.maxValue = _.max(bucket.values); if (bucket.minValue < bucketMatrix.minValue) { bucketMatrix.minValue = bucket.minValue; } if (bucket.maxValue > bucketMatrix.maxValue) { bucketMatrix.maxValue = bucket.maxValue; } bucket.value = bucket.maxValue; if (bucket.values.length > 1) { bucketMatrix.multipleValues = true; bucket.multipleValues = true; bucket.value = this.panel.seriesFilterIndex != -1 ? bucket.values[this.panel.seriesFilterIndex] : bucket.maxValue; } }) }); bucketMatrix.xBucketSize = Number.MIN_SAFE_INTEGER; targetKeys.map((target) => { let bucketsLen: number = bucketMatrix.buckets[target].length; if (bucketsLen > bucketMatrix.xBucketSize) { bucketMatrix.xBucketSize = bucketsLen; } }); //console.log ("bucketMatrix with values: ", bucketMatrix); bucketMatrix.targets = targetKeys; return bucketMatrix; } } export { StatusHeatmapCtrl, StatusHeatmapCtrl as PanelCtrl };