1import { MetricsPanelCtrl } from 'app/plugins/sdk'; 2import { defaultsDeep, includes, keys, map, reduce, min as _min, max as _max } from 'lodash'; 3import kbn from 'app/core/utils/kbn'; 4import TimeSeries from 'app/core/time_series2'; 5import { axesEditor } from './axes_editor'; 6import { heatmapDisplayEditor } from './display_editor'; 7import rendering from './rendering'; 8import { 9 convertToHeatMap, 10 convertToCards, 11 histogramToHeatmap, 12 calculateBucketSize, 13 sortSeriesByLabel, 14} from './heatmap_data_converter'; 15import { auto } from 'angular'; 16import { getProcessedDataFrames } from 'app/features/query/state/runRequest'; 17import { DataProcessor } from '../graph/data_processor'; 18import { LegacyResponseData, PanelEvents, DataFrame, rangeUtil } from '@grafana/data'; 19import { TemplateSrv } from 'app/features/templating/template_srv'; 20import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; 21import appEvents from 'app/core/app_events'; 22import { ZoomOutEvent } from 'app/types/events'; 23 24const X_BUCKET_NUMBER_DEFAULT = 30; 25const Y_BUCKET_NUMBER_DEFAULT = 10; 26 27const panelDefaults: any = { 28 heatmap: {}, 29 cards: { 30 cardPadding: null, 31 cardRound: null, 32 }, 33 color: { 34 mode: 'spectrum', 35 cardColor: '#b4ff00', 36 colorScale: 'sqrt', 37 exponent: 0.5, 38 colorScheme: 'interpolateOranges', 39 }, 40 legend: { 41 show: false, 42 }, 43 dataFormat: 'timeseries', 44 yBucketBound: 'auto', 45 reverseYBuckets: false, 46 xAxis: { 47 show: true, 48 }, 49 yAxis: { 50 show: true, 51 format: 'short', 52 decimals: null, 53 logBase: 1, 54 splitFactor: null, 55 min: null, 56 max: null, 57 }, 58 xBucketSize: null, 59 xBucketNumber: null, 60 yBucketSize: null, 61 yBucketNumber: null, 62 tooltip: { 63 show: true, 64 showHistogram: false, 65 }, 66 highlightCards: true, 67 hideZeroBuckets: false, 68}; 69 70const colorModes = ['opacity', 'spectrum']; 71const opacityScales = ['linear', 'sqrt']; 72 73// Schemes from d3-scale-chromatic 74// https://github.com/d3/d3-scale-chromatic 75const colorSchemes = [ 76 // Diverging 77 { name: 'Spectral', value: 'interpolateSpectral', invert: 'always' }, 78 { name: 'RdYlGn', value: 'interpolateRdYlGn', invert: 'always' }, 79 80 // Sequential (Single Hue) 81 { name: 'Blues', value: 'interpolateBlues', invert: 'dark' }, 82 { name: 'Greens', value: 'interpolateGreens', invert: 'dark' }, 83 { name: 'Greys', value: 'interpolateGreys', invert: 'dark' }, 84 { name: 'Oranges', value: 'interpolateOranges', invert: 'dark' }, 85 { name: 'Purples', value: 'interpolatePurples', invert: 'dark' }, 86 { name: 'Reds', value: 'interpolateReds', invert: 'dark' }, 87 88 // Sequential (Multi-Hue) 89 { name: 'Turbo', value: 'interpolateTurbo', invert: 'light' }, 90 { name: 'Cividis', value: 'interpolateCividis', invert: 'light' }, 91 { name: 'Viridis', value: 'interpolateViridis', invert: 'light' }, 92 { name: 'Magma', value: 'interpolateMagma', invert: 'light' }, 93 { name: 'Inferno', value: 'interpolateInferno', invert: 'light' }, 94 { name: 'Plasma', value: 'interpolatePlasma', invert: 'light' }, 95 { name: 'Warm', value: 'interpolateWarm', invert: 'light' }, 96 { name: 'Cool', value: 'interpolateCool', invert: 'light' }, 97 { name: 'Cubehelix', value: 'interpolateCubehelixDefault', invert: 'light' }, 98 { name: 'BuGn', value: 'interpolateBuGn', invert: 'dark' }, 99 { name: 'BuPu', value: 'interpolateBuPu', invert: 'dark' }, 100 { name: 'GnBu', value: 'interpolateGnBu', invert: 'dark' }, 101 { name: 'OrRd', value: 'interpolateOrRd', invert: 'dark' }, 102 { name: 'PuBuGn', value: 'interpolatePuBuGn', invert: 'dark' }, 103 { name: 'PuBu', value: 'interpolatePuBu', invert: 'dark' }, 104 { name: 'PuRd', value: 'interpolatePuRd', invert: 'dark' }, 105 { name: 'RdPu', value: 'interpolateRdPu', invert: 'dark' }, 106 { name: 'YlGnBu', value: 'interpolateYlGnBu', invert: 'dark' }, 107 { name: 'YlGn', value: 'interpolateYlGn', invert: 'dark' }, 108 { name: 'YlOrBr', value: 'interpolateYlOrBr', invert: 'dark' }, 109 { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' }, 110]; 111 112const dsSupportHistogramSort = ['elasticsearch']; 113 114export class HeatmapCtrl extends MetricsPanelCtrl { 115 static templateUrl = 'module.html'; 116 117 opacityScales: any = []; 118 colorModes: any = []; 119 colorSchemes: any = []; 120 selectionActivated: boolean; 121 unitFormats: any; 122 data: any; 123 series: TimeSeries[] = []; 124 dataWarning: any; 125 decimals = 0; 126 scaledDecimals = 0; 127 128 processor: DataProcessor; // Shared with graph panel 129 130 /** @ngInject */ 131 constructor($scope: any, $injector: auto.IInjectorService, templateSrv: TemplateSrv, timeSrv: TimeSrv) { 132 super($scope, $injector); 133 134 this.selectionActivated = false; 135 136 defaultsDeep(this.panel, panelDefaults); 137 this.opacityScales = opacityScales; 138 this.colorModes = colorModes; 139 this.colorSchemes = colorSchemes; 140 141 // Use DataFrames 142 this.useDataFrames = true; 143 this.processor = new DataProcessor({ 144 xaxis: { mode: 'custom' }, // NOT: 'histogram' :) 145 aliasColors: {}, // avoids null reference 146 }); 147 148 // Bind grafana panel events 149 this.events.on(PanelEvents.render, this.onRender.bind(this)); 150 this.events.on(PanelEvents.dataFramesReceived, this.onDataFramesReceived.bind(this)); 151 this.events.on(PanelEvents.dataSnapshotLoad, this.onSnapshotLoad.bind(this)); 152 this.events.on(PanelEvents.editModeInitialized, this.onInitEditMode.bind(this)); 153 154 this.onCardColorChange = this.onCardColorChange.bind(this); 155 } 156 157 onInitEditMode() { 158 this.addEditorTab('Axes', axesEditor, 2); 159 this.addEditorTab('Display', heatmapDisplayEditor, 3); 160 this.unitFormats = kbn.getUnitFormats(); 161 } 162 163 zoomOut(evt: any) { 164 appEvents.publish(new ZoomOutEvent(2)); 165 } 166 167 onRender() { 168 if (this.panel.dataFormat === 'tsbuckets') { 169 this.convertHistogramToHeatmapData(); 170 } else { 171 this.convertTimeSeriesToHeatmapData(); 172 } 173 } 174 175 convertTimeSeriesToHeatmapData() { 176 if (!this.range || !this.series) { 177 return; 178 } 179 180 let xBucketSize, yBucketSize, bucketsData, heatmapStats; 181 const logBase = this.panel.yAxis.logBase; 182 183 const xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT; 184 const xBucketSizeByNumber = Math.floor((this.range.to.valueOf() - this.range.from.valueOf()) / xBucketNumber); 185 186 // Parse X bucket size (number or interval) 187 const isIntervalString = kbn.intervalRegex.test(this.panel.xBucketSize); 188 if (isIntervalString) { 189 xBucketSize = rangeUtil.intervalToMs(this.panel.xBucketSize); 190 } else if ( 191 isNaN(Number(this.panel.xBucketSize)) || 192 this.panel.xBucketSize === '' || 193 this.panel.xBucketSize === null 194 ) { 195 xBucketSize = xBucketSizeByNumber; 196 } else { 197 xBucketSize = Number(this.panel.xBucketSize); 198 } 199 200 // Calculate Y bucket size 201 heatmapStats = this.parseSeries(this.series); 202 const yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT; 203 if (logBase !== 1) { 204 yBucketSize = this.panel.yAxis.splitFactor; 205 } else { 206 if (heatmapStats.max === heatmapStats.min) { 207 if (heatmapStats.max) { 208 yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT; 209 } else { 210 yBucketSize = 1; 211 } 212 } else { 213 yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber; 214 } 215 yBucketSize = this.panel.yBucketSize || yBucketSize; 216 } 217 218 bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase); 219 220 // Set default Y range if no data 221 if (!heatmapStats.min && !heatmapStats.max) { 222 heatmapStats = { min: -1, max: 1, minLog: 1 }; 223 yBucketSize = 1; 224 } 225 226 const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets); 227 228 this.data = { 229 buckets: bucketsData, 230 heatmapStats: heatmapStats, 231 xBucketSize: xBucketSize, 232 yBucketSize: yBucketSize, 233 cards: cards, 234 cardStats: cardStats, 235 }; 236 } 237 238 convertHistogramToHeatmapData() { 239 if (!this.range || !this.series) { 240 return; 241 } 242 243 const panelDatasource = this.getPanelDataSourceType(); 244 let xBucketSize, yBucketSize, bucketsData, tsBuckets; 245 246 // Try to sort series by bucket bound, if datasource doesn't do it. 247 if (!includes(dsSupportHistogramSort, panelDatasource)) { 248 this.series.sort(sortSeriesByLabel); 249 } 250 251 if (this.panel.reverseYBuckets) { 252 this.series.reverse(); 253 } 254 255 // Convert histogram to heatmap. Each histogram bucket represented by the series which name is 256 // a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as Y axis labels. 257 bucketsData = histogramToHeatmap(this.series); 258 259 tsBuckets = map(this.series, 'label'); 260 const yBucketBound = this.panel.yBucketBound; 261 if ( 262 (panelDatasource === 'prometheus' && yBucketBound !== 'lower' && yBucketBound !== 'middle') || 263 yBucketBound === 'upper' 264 ) { 265 // Prometheus labels are upper inclusive bounds, so add empty bottom bucket label. 266 tsBuckets = [''].concat(tsBuckets); 267 } else { 268 // Elasticsearch uses labels as lower bucket bounds, so add empty top bucket label. 269 // Use this as a default mode as well. 270 tsBuckets.push(''); 271 } 272 273 // Calculate bucket size based on heatmap data 274 const xBucketBoundSet = map(keys(bucketsData), (key) => Number(key)); 275 xBucketSize = calculateBucketSize(xBucketBoundSet); 276 // Always let yBucketSize=1 in 'tsbuckets' mode 277 yBucketSize = 1; 278 279 const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets); 280 281 this.data = { 282 buckets: bucketsData, 283 xBucketSize: xBucketSize, 284 yBucketSize: yBucketSize, 285 tsBuckets: tsBuckets, 286 cards: cards, 287 cardStats: cardStats, 288 }; 289 } 290 291 getPanelDataSourceType() { 292 if (this.datasource && this.datasource.meta && this.datasource.meta.id) { 293 return this.datasource.meta.id; 294 } else { 295 return 'unknown'; 296 } 297 } 298 299 // This should only be called from the snapshot callback 300 onSnapshotLoad(dataList: LegacyResponseData[]) { 301 this.onDataFramesReceived(getProcessedDataFrames(dataList)); 302 } 303 304 // Directly support DataFrame 305 onDataFramesReceived(data: DataFrame[]) { 306 this.series = this.processor.getSeriesList({ dataList: data, range: this.range }).map((ts) => { 307 ts.color = undefined; // remove whatever the processor set 308 ts.flotpairs = ts.getFlotPairs(this.panel.nullPointMode); 309 return ts; 310 }); 311 312 this.dataWarning = null; 313 const datapointsCount = reduce( 314 this.series, 315 (sum, series) => { 316 return sum + series.datapoints.length; 317 }, 318 0 319 ); 320 321 if (datapointsCount === 0) { 322 this.dataWarning = { 323 title: 'No data points', 324 tip: 'No datapoints returned from data query', 325 }; 326 } else { 327 for (const series of this.series) { 328 if (series.isOutsideRange) { 329 this.dataWarning = { 330 title: 'Data points outside time range', 331 tip: 'Can be caused by timezone mismatch or missing time filter in query', 332 }; 333 break; 334 } 335 } 336 } 337 338 this.render(); 339 } 340 341 onDataError() { 342 this.series = []; 343 this.render(); 344 } 345 346 onCardColorChange(newColor: any) { 347 this.panel.color.cardColor = newColor; 348 this.render(); 349 } 350 351 parseSeries(series: TimeSeries[]) { 352 const min = _min(map(series, (s) => s.stats.min)); 353 const minLog = _min(map(series, (s) => s.stats.logmin)); 354 const max = _max(map(series, (s) => s.stats.max)); 355 356 return { 357 max, 358 min, 359 minLog, 360 }; 361 } 362 363 parseHistogramSeries(series: TimeSeries[]) { 364 const bounds = map(series, (s) => Number(s.alias)); 365 const min = _min(bounds); 366 const minLog = _min(bounds); 367 const max = _max(bounds); 368 369 return { 370 max: max, 371 min: min, 372 minLog: minLog, 373 }; 374 } 375 376 link(scope: any, elem: any, attrs: any, ctrl: any) { 377 rendering(scope, elem, attrs, ctrl); 378 } 379} 380